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"