diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index ff4fc51e..f75d1011 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -175,7 +175,8 @@

- {missingInboxes.length} {missingInboxes.length > 1 ? 'inboxes are' : 'inbox is'} not configured. + {missingInboxes.length} + {missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.

In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make diff --git a/src/app/components/ProfileMultiSelect.svelte b/src/app/components/ProfileMultiSelect.svelte index 43431ae2..03de2f85 100644 --- a/src/app/components/ProfileMultiSelect.svelte +++ b/src/app/components/ProfileMultiSelect.svelte @@ -3,7 +3,7 @@ import {type Instance} from "tippy.js" import {append, remove, uniq} from "@welshman/lib" import {profileSearch} from "@welshman/app" - import {Suggestions} from "@welshman/editor" + import Suggestions from "@lib/components/Suggestions.svelte" import Icon from "@lib/components/Icon.svelte" import Tippy from "@lib/components/Tippy.svelte" import Button from "@lib/components/Button.svelte" diff --git a/src/app/editor/ProfileSuggestion.svelte b/src/app/editor/ProfileSuggestion.svelte index 8aeae37a..d07c356e 100644 --- a/src/app/editor/ProfileSuggestion.svelte +++ b/src/app/editor/ProfileSuggestion.svelte @@ -10,9 +10,8 @@ import WotScore from "@lib/components/WotScore.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte" - const {value} = $props() + const {pubkey} = $props() - const pubkey = value const profileDisplay = deriveProfileDisplay(pubkey) const handle = deriveHandleForPubkey(pubkey) const score = deriveUserWotScore(pubkey) diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index dbc088e4..5d033edc 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -1,11 +1,12 @@ import {asClassComponent} from "svelte/legacy" +import {mount} from "svelte" import type {Writable} from "svelte/store" -import {derived} from "svelte/store" +import {get} from "svelte/store" import {Editor, SvelteNodeViewRenderer} from "svelte-tiptap" import {ctx} from "@welshman/lib" import type {StampedEvent} from "@welshman/util" import {signer, profileSearch} from "@welshman/app" -import {MentionSuggestion, WelshmanExtension} from "@welshman/editor" +import {MentionSuggestion, WelshmanExtension} from "@lib/editor" import {getSetting, userSettingValues} from "@app/state" import ProfileSuggestion from "./ProfileSuggestion.svelte" import EditMention from "./EditMention.svelte" @@ -82,9 +83,15 @@ export const makeEditor = ({ return [ MentionSuggestion({ editor: (this as any).editor, - search: derived(profileSearch, s => s.searchValues), + search: (term: string) => get(profileSearch).searchValues(term), getRelays: (pubkey: string) => ctx.app.router.FromPubkeys([pubkey]).getUrls(), - component: asClassComponent(ProfileSuggestion), + createSuggestion: (pubkey: string) => { + const target = document.createElement("div") + + mount(ProfileSuggestion, {target, props: {pubkey}}) + + return target + }, }), ] }, diff --git a/src/lib/components/SearchSelect.svelte b/src/lib/components/SearchSelect.svelte index 7aebee8a..4e3912d2 100644 --- a/src/lib/components/SearchSelect.svelte +++ b/src/lib/components/SearchSelect.svelte @@ -4,7 +4,8 @@ import {type Instance} from "tippy.js" import {identity} from "@welshman/lib" import {createSearch} from "@welshman/app" - import {Suggestions, SuggestionString} from "@welshman/editor" + import Suggestions from "@lib/components/Suggestions.svelte" + import SuggestionString from "@lib/components/SuggestionString.svelte" import Icon from "@lib/components/Icon.svelte" import Tippy from "@lib/components/Tippy.svelte" diff --git a/src/lib/components/SuggestionString.svelte b/src/lib/components/SuggestionString.svelte new file mode 100644 index 00000000..c19d2614 --- /dev/null +++ b/src/lib/components/SuggestionString.svelte @@ -0,0 +1,5 @@ + + +{value} diff --git a/src/lib/components/Suggestions.svelte b/src/lib/components/Suggestions.svelte new file mode 100644 index 00000000..63c43565 --- /dev/null +++ b/src/lib/components/Suggestions.svelte @@ -0,0 +1,84 @@ + + + + +{#if term} +

+
+ {#if term && allowCreate && !items.includes(term)} + + {/if} + {#each items as value, i (value)} + + {/each} +
+ {#if items.length === 0} +
No results
+ {/if} +
+{/if} diff --git a/src/lib/editor/components/EditBolt11.svelte b/src/lib/editor/components/EditBolt11.svelte new file mode 100644 index 00000000..4cbaf62d --- /dev/null +++ b/src/lib/editor/components/EditBolt11.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/lib/editor/components/EditEvent.svelte b/src/lib/editor/components/EditEvent.svelte new file mode 100644 index 00000000..b5582a57 --- /dev/null +++ b/src/lib/editor/components/EditEvent.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/lib/editor/components/EditMedia.svelte b/src/lib/editor/components/EditMedia.svelte new file mode 100644 index 00000000..75274577 --- /dev/null +++ b/src/lib/editor/components/EditMedia.svelte @@ -0,0 +1,14 @@ + + + + {node.attrs.file?.name || node.attrs.src} + diff --git a/src/lib/editor/components/EditMention.svelte b/src/lib/editor/components/EditMention.svelte new file mode 100644 index 00000000..3aaa53a6 --- /dev/null +++ b/src/lib/editor/components/EditMention.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/lib/editor/components/index.ts b/src/lib/editor/components/index.ts new file mode 100644 index 00000000..a7ab0760 --- /dev/null +++ b/src/lib/editor/components/index.ts @@ -0,0 +1,4 @@ +export {default as EditBolt11} from "./EditBolt11.svelte" +export {default as EditMedia} from "./EditMedia.svelte" +export {default as EditEvent} from "./EditEvent.svelte" +export {default as EditMention} from "./EditMention.svelte" diff --git a/src/lib/editor/extensions/BreakOrSubmit.ts b/src/lib/editor/extensions/BreakOrSubmit.ts new file mode 100644 index 00000000..775eca31 --- /dev/null +++ b/src/lib/editor/extensions/BreakOrSubmit.ts @@ -0,0 +1,31 @@ +import {HardBreak, type HardBreakOptions} from "@tiptap/extension-hard-break" + +export interface BreakOrSubmitOptions extends HardBreakOptions { + /** Handler for when enter is pressed. */ + submit: () => void + + /** Whether to call `submit` on unmodified Enter */ + aggressive?: boolean +} + +export const BreakOrSubmit = HardBreak.extend({ + addKeyboardShortcuts() { + return { + "Shift-Enter": () => this.editor.commands.setHardBreak(), + "Mod-Enter": () => { + this.options.submit() + + return true + }, + Enter: () => { + if (this.options.aggressive) { + this.options.submit() + + return true + } + + return false + }, + } + }, +}) diff --git a/src/lib/editor/extensions/CodeInline.ts b/src/lib/editor/extensions/CodeInline.ts new file mode 100644 index 00000000..c26061de --- /dev/null +++ b/src/lib/editor/extensions/CodeInline.ts @@ -0,0 +1,75 @@ +import {InputRule, mergeAttributes, Node, PasteRule} from "@tiptap/core" +import type {CodeOptions} from "@tiptap/extension-code" + +const inputRegex = /(?:^|\s)(`(?!\s+`)((?:[^`]+))`(?!\s+`))$/ + +const pasteRegex = /(?:^|\s)(`(?!\s+`)((?:[^`]+))`(?!\s+`))/g + +export type CodeInlineOptions = object + +export const CodeInline = Node.create({ + name: "codeInline", + content: "text*", + marks: "", + group: "inline", + inline: true, + code: true, + defining: true, + addOptions() { + return { + HTMLAttributes: {}, + } + }, + parseHTML() { + return [{tag: "code"}] + }, + renderHTML({HTMLAttributes}) { + return ["code", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + addKeyboardShortcuts() { + return { + // remove code block when at start of document or code block is empty + Backspace: () => { + const {empty, $anchor, $from} = this.editor.state.selection + + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2 + + if (!empty || $anchor.parent.type.name !== this.name) { + return false + } + + if (isAtEnd) { + const {tr} = this.editor.state + tr.delete($from.start(), $from.end() + 1) + this.editor.view.dispatch(tr) + } + + return false + }, + } + }, + addInputRules() { + return [ + new InputRule({ + find: inputRegex, + handler: ({state, range, match}) => { + const textNode = state.schema.text(match[2]) + const codeNode = this.type.create(null, textNode) + state.tr.replaceWith(range.from, range.to, codeNode).insertText(" ") + }, + }), + ] + }, + addPasteRules() { + return [ + new PasteRule({ + find: pasteRegex, + handler: ({state, range, match}) => { + const textNode = state.schema.text(match[2]) + const codeNode = this.type.create(null, textNode) + state.tr.replaceWith(range.from, range.to, codeNode).insertText(" ") + }, + }), + ] + }, +}) diff --git a/src/lib/editor/extensions/Welshman.ts b/src/lib/editor/extensions/Welshman.ts new file mode 100644 index 00000000..be25e91e --- /dev/null +++ b/src/lib/editor/extensions/Welshman.ts @@ -0,0 +1,228 @@ +import type {StampedEvent, SignedEvent} from "@welshman/util" +import {deepMergeLeft} from "@welshman/lib" +import {SvelteNodeViewRenderer} from "svelte-tiptap" +import type {Extensions, AnyExtension} from "@tiptap/core" +import {CodeBlock} from "@tiptap/extension-code-block" +import type {CodeBlockOptions} from "@tiptap/extension-code-block" +import {Document} from "@tiptap/extension-document" +import {Dropcursor} from "@tiptap/extension-dropcursor" +import type {DropcursorOptions} from "@tiptap/extension-dropcursor" +import {Gapcursor} from "@tiptap/extension-gapcursor" +import {History} from "@tiptap/extension-history" +import type {HistoryOptions} from "@tiptap/extension-history" +import {Paragraph} from "@tiptap/extension-paragraph" +import type {ParagraphOptions} from "@tiptap/extension-paragraph" +import {Text} from "@tiptap/extension-text" +import {Placeholder} from "@tiptap/extension-placeholder" +import type {PlaceholderOptions} from "@tiptap/extension-placeholder" +import type { + NostrOptions, + FileUploadOptions, + ImageOptions, + LinkOptions, + NSecRejectOptions, +} from "nostr-editor" +import { + NostrExtension, + Bolt11Extension, + FileUploadExtension, + ImageExtension, + LinkExtension, + NAddrExtension, + NEventExtension, + NProfileExtension, + TagExtension, + VideoExtension, + NSecRejectExtension, +} from "nostr-editor" +import {WordCount} from "./WordCount.js" +import {CodeInline, type CodeInlineOptions} from "./CodeInline.js" +import {BreakOrSubmit, type BreakOrSubmitOptions} from "./BreakOrSubmit.js" +import {EditBolt11, EditMedia, EditEvent, EditMention} from "../components/index.js" + +export type ChildExtensionOptions = + | false + | { + extend?: Partial + config?: Partial + } + +export type EmptyOptions = object + +export type WelshmanExtensionOptions = { + bolt11?: false + breakOrSubmit?: ChildExtensionOptions + codeInline?: ChildExtensionOptions + codeBlock?: ChildExtensionOptions + document?: false + dropcursor?: ChildExtensionOptions + fileUpload?: ChildExtensionOptions + gapcursor?: false + history?: ChildExtensionOptions + image?: ChildExtensionOptions + link?: ChildExtensionOptions + naddr?: ChildExtensionOptions + nevent?: ChildExtensionOptions + nprofile?: ChildExtensionOptions + nsecReject?: ChildExtensionOptions + paragraph?: ChildExtensionOptions + placeholder?: ChildExtensionOptions + tag?: false + text?: false + video?: false + wordCount?: false +} + +export interface WelshmanOptions extends NostrOptions { + submit?: () => void + sign?: (event: StampedEvent) => Promise + defaultUploadUrl?: string + defaultUploadType?: "nip96" | "blossom" + extensions?: WelshmanExtensionOptions +} + +export const WelshmanExtension = NostrExtension.extend({ + // Return an empty object or else options can't be passed + addOptions() { + return {} + }, + + addExtensions() { + const { + sign, + submit, + defaultUploadUrl = "https://nostr.build", + defaultUploadType = "nip96", + } = this.options + + if (!sign) throw new Error("sign is a required argument to WelshmanExtension") + if (!submit) throw new Error("submit is a required argument to WelshmanExtension") + + const extensionOptions = deepMergeLeft(this.options.extensions || {}, { + codeInline: { + extend: { + renderText: (props: any) => "`" + props.node.textContent + "`", + }, + }, + codeBlock: { + extend: { + renderText: (props: any) => "```" + props.node.textContent + "```", + }, + }, + bolt11: { + config: { + inline: true, + group: "inline", + }, + extend: { + addNodeView: () => SvelteNodeViewRenderer(EditBolt11), + }, + }, + image: { + config: { + inline: true, + group: "inline", + defaultUploadUrl, + defaultUploadType, + }, + extend: { + addNodeView: () => SvelteNodeViewRenderer(EditMedia), + }, + }, + video: { + config: { + inline: true, + group: "inline", + defaultUploadUrl, + defaultUploadType, + }, + extend: { + addNodeView: () => SvelteNodeViewRenderer(EditMedia), + }, + }, + nevent: { + config: { + inline: true, + group: "inline", + }, + extend: { + addNodeView: () => SvelteNodeViewRenderer(EditEvent), + }, + }, + naddr: { + config: { + inline: true, + group: "inline", + }, + extend: { + addNodeView: () => SvelteNodeViewRenderer(EditEvent), + }, + }, + nprofile: { + extend: { + addNodeView: () => SvelteNodeViewRenderer(EditMention), + }, + }, + breakOrSubmit: { + config: { + submit, + }, + }, + fileUpload: { + config: { + sign, + immediateUpload: true, + allowedMimeTypes: [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", + "video/mpeg", + "video/webm", + ], + }, + }, + }) as WelshmanExtensionOptions + + const extensions: Extensions = [] + + const addExtension = (extension: AnyExtension, options?: ChildExtensionOptions | false) => { + if (options === false) return + + if (options?.extend) { + extension = extension.extend(options.extend) + } + + if (options?.config) { + extension = extension.configure(options.config) + } + + extensions.push(extension) + } + + addExtension(Document, extensionOptions.document) + addExtension(Text, extensionOptions.text) + addExtension(Paragraph, extensionOptions.paragraph) + addExtension(History, extensionOptions.history) + addExtension(CodeBlock, extensionOptions.codeBlock) + addExtension(CodeInline, extensionOptions.codeInline) + addExtension(Dropcursor, extensionOptions.dropcursor) + addExtension(FileUploadExtension, extensionOptions.fileUpload) + addExtension(Gapcursor, extensionOptions.gapcursor) + addExtension(BreakOrSubmit, extensionOptions.breakOrSubmit) + addExtension(ImageExtension, extensionOptions.image) + addExtension(LinkExtension, extensionOptions.link) + addExtension(NAddrExtension, extensionOptions.naddr) + addExtension(NEventExtension, extensionOptions.nevent) + addExtension(NProfileExtension, extensionOptions.nprofile) + addExtension(NSecRejectExtension, extensionOptions.nsecReject) + addExtension(Placeholder, extensionOptions.placeholder) + addExtension(TagExtension, extensionOptions.tag) + addExtension(VideoExtension, extensionOptions.video) + addExtension(Bolt11Extension, extensionOptions.bolt11) + addExtension(WordCount, extensionOptions.wordCount) + + return extensions + }, +}) diff --git a/src/lib/editor/extensions/WordCount.ts b/src/lib/editor/extensions/WordCount.ts new file mode 100644 index 00000000..333d3d4b --- /dev/null +++ b/src/lib/editor/extensions/WordCount.ts @@ -0,0 +1,19 @@ +import {Extension} from "@tiptap/core" + +export const WordCount = Extension.create({ + name: "wordCount", + + addStorage() { + return { + words: 0, + chars: 0, + } + }, + + onUpdate() { + const text = this.editor.state.doc.textContent + + this.storage.words = text.split(/\s+/).filter(word => word.length > 0).length + this.storage.chars = text.length + }, +}) diff --git a/src/lib/editor/extensions/index.ts b/src/lib/editor/extensions/index.ts new file mode 100644 index 00000000..b532d025 --- /dev/null +++ b/src/lib/editor/extensions/index.ts @@ -0,0 +1,4 @@ +export * from "./BreakOrSubmit.js" +export * from "./CodeInline.js" +export * from "./Welshman.js" +export * from "./WordCount.js" diff --git a/src/lib/editor/index.css b/src/lib/editor/index.css new file mode 100644 index 00000000..b9af8204 --- /dev/null +++ b/src/lib/editor/index.css @@ -0,0 +1,105 @@ +:root { + --tiptap-object-bg: #eee; + --tiptap-object-fg: #111; + --tiptap-active-bg: #ddd; + --tiptap-active-fg: #111; +} + +.tiptap { + outline: none; + min-height: 0; + height: 100%; +} + +.tiptap p.is-editor-empty:first-child::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.tiptap pre code { + display: block; + max-width: 100%; + overflow: auto; + padding: 0.25rem; + background-color: var(--tiptap-object-bg); + color: var(--tiptap-object-fg); +} + +.tiptap .tiptap-object, +.tiptap p code, +.tiptap [tag] { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 3px; + padding: 0 0.25rem; + background-color: var(--tiptap-object-bg); + color: var(--tiptap-object-fg); +} + +.tiptap .tiptap-active { + background-color: var(--tiptap-active-bg); + color: var(--tiptap-active-fg); +} + +.tiptap .tiptap-uploading { + animation: tiptapFileUpload 1.5s infinite; +} + +.tiptap-suggestions { + margin-top: 0.5rem; + max-height: 350px; +} + +.tiptap-suggestions__content { + border-radius: 3px; + box-shadow: 0px 5px 8px 0px rgba(0, 0, 0, 0.2); + overflow-y: auto; + overflow-x: hidden; +} + +.tiptap-suggestions__create, +.tiptap-suggestions__item { + white-space: nowrap; + display: block; + width: 100%; + min-width: 0px; + cursor: pointer; + overflow-x: hidden; + text-overflow: ellipsis; + padding: 0.5rem 1rem; + text-align: left; + transition-duration: 100ms; + transition-property: color, background-color; + background-color: var(--tiptap-object-bg); + color: var(--tiptap-object-fg); +} + +.tiptap-suggestions__selected, +.tiptap-suggestions__create:hover, +.tiptap-suggestions__item:hover { + background-color: var(--tiptap-active-bg); + color: var(--tiptap-active-fg); +} + +.tiptap-suggestions__empty { + display: flex; + gap: 0.5rem; + padding: 0.5rem 1rem; +} + +@keyframes tiptapFileUpload { + 0% { + opacity: 0.2; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.2; + } +} diff --git a/src/lib/editor/index.ts b/src/lib/editor/index.ts new file mode 100644 index 00000000..b09d8ffa --- /dev/null +++ b/src/lib/editor/index.ts @@ -0,0 +1,5 @@ +import "./index.css" + +export * from "./components/index.js" +export * from "./extensions/index.js" +export * from "./plugins/index.js" diff --git a/src/lib/editor/plugins/TippySuggestion.ts b/src/lib/editor/plugins/TippySuggestion.ts new file mode 100644 index 00000000..c88235c0 --- /dev/null +++ b/src/lib/editor/plugins/TippySuggestion.ts @@ -0,0 +1,309 @@ +import type {Instance} from "tippy.js" +import tippy from "tippy.js" +import {nprofileEncode} from "nostr-tools/nip19" +import type {Editor} from "@tiptap/core" +import {makeNProfileAttrs} from "nostr-editor" +import {PluginKey} from "@tiptap/pm/state" +import Suggestion from "@tiptap/suggestion" +import {throttle, enumerate, clamp} from "@welshman/lib" + +export type CreateSuggestion = (item: string) => HTMLElement + +export const defaultCreateSuggestion = (item: string) => { + const span = document.createElement("span") + + span.textContent = item + + return span +} + +export type SuggestionsWrapperProps = { + term: string + allowCreate: boolean + select: (value: string) => void + search: (term: string) => string[] + createSuggestion: CreateSuggestion +} + +export interface ISuggestionsWrapperConstructor { + new (target: HTMLElement, props: SuggestionsWrapperProps): ISuggestionsWrapper +} + +export interface ISuggestionsWrapper { + setProps: (props: SuggestionsWrapperProps) => void + onKeyDown: (event: Event) => boolean + destroy: () => void +} + +function createSuggestionsWrapper( + ctor: ISuggestionsWrapperConstructor, + target: HTMLElement, + props: SuggestionsWrapperProps, +): ISuggestionsWrapper { + return new ctor(target, props) +} + +export class DefaultSuggestionsWrapper implements ISuggestionsWrapper { + index = 0 + items: string[] = [] + target: HTMLElement + content: HTMLElement + props: SuggestionsWrapperProps + + constructor(target: HTMLElement, props: SuggestionsWrapperProps) { + this.target = target + this.props = props + this.content = document.createElement("div") + this.content.classList.add("tiptap-suggestions__content") + + target.appendChild(this.content) + target.classList.add("tiptap-suggestions") + + this.search() + this.render() + } + + search = throttle(300, () => { + const {term, search} = this.props + + this.items = search(term).slice(0, 5) + }) + + render() { + const {index} = this + const {select, term, allowCreate, createSuggestion} = this.props + + this.content.innerHTML = "" + + if (term && allowCreate && this.items.includes(term)) { + const button = document.createElement("button") + + button.classList.add("tiptap-suggestions__create") + + button.addEventListener("mousedown", (event: Event) => { + event.preventDefault() + event.stopPropagation() + }) + + button.addEventListener("click", (event: Event) => { + event.preventDefault() + event.stopPropagation() + select(term) + }) + + this.content.appendChild(button) + } + + for (const [i, item] of enumerate(this.items)) { + const button = document.createElement("button") + + button.classList.add("tiptap-suggestions__item") + + if (i === index) { + button.classList.add("tiptap-suggestions__selected") + } + + button.addEventListener("mousedown", (event: Event) => { + event.preventDefault() + event.stopPropagation() + }) + + button.addEventListener("click", (event: Event) => { + event.preventDefault() + event.stopPropagation() + select(item) + }) + + button.appendChild(createSuggestion(item)) + + this.content.appendChild(button) + } + } + + setIndex(index: number) { + this.index = clamp([0, this.items.length - 1], index) + this.render() + } + + setProps(props: SuggestionsWrapperProps) { + this.props = props + this.render() + } + + onKeyDown(event: any) { + const {index, items} = this + const {term, select, allowCreate} = this.props + + if (["Enter", "Tab"].includes(event.code)) { + const value = items[index] + + if (value) { + select(value) + + return true + } else if (term && allowCreate) { + select(term) + + return true + } + } + + if (event.code === "Space" && term && allowCreate) { + select(term) + + return true + } + + if (event.code === "ArrowUp") { + this.setIndex(index - 1) + + return true + } + + if (event.code === "ArrowDown") { + this.setIndex(index + 1) + + return true + } + + return false + } + + destroy() { + this.target.remove() + } +} + +export type TippySuggestionOptions = { + char: string + name: string + editor: Editor + search: (term: string) => string[] + select: (value: string, props: any) => void + allowCreate?: boolean + createSuggestion?: CreateSuggestion + suggestionsWrapper?: ISuggestionsWrapperConstructor +} + +export const TippySuggestion = ({ + char, + name, + editor, + search, + select, + allowCreate = false, + createSuggestion = defaultCreateSuggestion, + suggestionsWrapper = DefaultSuggestionsWrapper, +}: TippySuggestionOptions) => + Suggestion({ + char, + editor, + pluginKey: new PluginKey(`suggest-${name}`), + command: ({editor, range, props}) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter + const overrideSpace = nodeAfter?.text?.startsWith(" ") + + if (overrideSpace) { + range.to += 1 + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + {type: name, attrs: props}, + {type: "text", text: " "}, + ]) + .run() + + window.getSelection()?.collapseToEnd() + }, + allow: ({state, range}) => { + const $from = state.doc.resolve(range.from) + const type = state.schema.nodes[name] + + return !!$from.parent.type.contentMatch.matchType(type) + }, + render: () => { + let popover: Instance[] + let wrapper: ISuggestionsWrapper + + const mapProps = (props: any) => ({ + term: props.query, + search, + allowCreate, + createSuggestion, + select: (value: string) => select(value, props), + }) + + return { + onStart: props => { + const target = document.createElement("div") + + // @ts-ignore + popover = tippy("body", { + getReferenceClientRect: props.clientRect as any, + appendTo: document.querySelector("dialog[open]") || document.body, + content: target, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }) + + if (!props.query) popover[0].hide() + + wrapper = createSuggestionsWrapper(suggestionsWrapper, target, mapProps(props)) + }, + onUpdate: props => { + if (props.query) { + popover[0].show() + } else { + popover[0].hide() + } + + wrapper.setProps(mapProps(props)) + + if (props.clientRect) { + popover[0].setProps({ + getReferenceClientRect: props.clientRect as any, + }) + } + }, + onKeyDown: props => { + if (props.event.key === "Escape") { + popover[0].hide() + + return true + } + + return Boolean(wrapper.onKeyDown(props.event)) + }, + onExit: () => { + popover[0].destroy() + wrapper.destroy() + }, + } + }, + }) + +export type MentionSuggestionOptions = Partial & { + editor: Editor + search: (term: string) => string[] + getRelays: (pubkey: string) => string[] +} + +export const MentionSuggestion = (options: MentionSuggestionOptions) => + TippySuggestion({ + char: "@", + name: "nprofile", + select: (pubkey: string, props: any) => { + const relays = options.getRelays(pubkey) + const bech32 = nprofileEncode({pubkey, relays}) + + return props.command(makeNProfileAttrs(bech32, {})) + }, + ...options, + }) diff --git a/src/lib/editor/plugins/index.ts b/src/lib/editor/plugins/index.ts new file mode 100644 index 00000000..6b8cb0a5 --- /dev/null +++ b/src/lib/editor/plugins/index.ts @@ -0,0 +1 @@ +export * from "./TippySuggestion.js"