Add topic extension

This commit is contained in:
Jon Staab
2024-08-22 17:25:53 -07:00
parent dc4dcb1ea2
commit d6fa0a85bc
9 changed files with 281 additions and 192 deletions

View File

@@ -5,13 +5,14 @@
import {Extension} from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import HardBreakExtension from '@tiptap/extension-hard-break'
import {NostrExtension} from 'nostr-editor'
import {Bolt11Extension, NProfileExtension, NEventExtension, NAddrExtension, ImageExtension, VideoExtension, FileUploadExtension} from 'nostr-editor'
import type {StampedEvent} from '@welshman/util'
import {createEvent, CHAT_MESSAGE} from '@welshman/util'
import {LinkExtension, createSuggestions, findNodes} from '@lib/tiptap'
import {LinkExtension, TopicExtension, createSuggestions, findNodes} from '@lib/tiptap'
import Icon from '@lib/components/Icon.svelte'
import Button from '@lib/components/Button.svelte'
import GroupComposeMention from '@app/components/GroupComposeMention.svelte'
import GroupComposeTopic from '@app/components/GroupComposeTopic.svelte'
import GroupComposeEvent from '@app/components/GroupComposeEvent.svelte'
import GroupComposeImage from '@app/components/GroupComposeImage.svelte'
import GroupComposeBolt11 from '@app/components/GroupComposeBolt11.svelte'
@@ -35,11 +36,7 @@
const sendMessage = () => {
console.log($editor.getJSON())
console.log(findNodes($editor.getJSON(), 'mention'))
console.log(findNodes($editor.getJSON(), 'nprofile'))
console.log(findNodes($editor.getJSON(), 'nevent'))
console.log(findNodes($editor.getJSON(), 'naddr'))
console.log(findNodes($editor.getJSON(), 'image'))
$editor.chain().clearContent().run()
createEvent(CHAT_MESSAGE, {
content: '',
tags: [],
@@ -66,10 +63,15 @@
addKeyboardShortcuts() {
return {
'Shift-Enter': () => this.editor.commands.setHardBreak(),
'Mod-Enter': () => {
uploadFiles()
'Mod-Enter': () => this.editor.commands.setHardBreak(),
'Enter': () => {
if (this.editor.getText().trim()) {
uploadFiles()
return true
return true
}
return false
},
}
}
@@ -77,52 +79,59 @@
LinkExtension.extend({
addNodeView: () => SvelteNodeViewRenderer(GroupComposeLink),
}),
NostrExtension.configure({
extend: {
bolt11: asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeBolt11)}),
nprofile: asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeMention)}),
nevent: asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeEvent)}),
naddr: asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeEvent)}),
image: asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeImage)}),
video: asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeVideo)}),
Bolt11Extension.extend({addNodeView: () => SvelteNodeViewRenderer(GroupComposeBolt11)}),
NProfileExtension.extend({
addNodeView: () => SvelteNodeViewRenderer(GroupComposeMention),
addProseMirrorPlugins() {
return [
createSuggestions({
char: '@',
name: 'nprofile',
editor: this.editor,
search: searchProfiles,
select: (pubkey: string, props: any) => props.command({pubkey}),
suggestionComponent: GroupComposeProfileSuggestion,
suggestionsComponent: GroupComposeSuggestions,
}),
]
}
}),
NEventExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeEvent)})),
NAddrExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeEvent)})),
ImageExtension
.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeImage)}))
.configure({defaultUploadUrl: 'https://nostr.build', defaultUploadType: 'nip96'}),
VideoExtension
.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeVideo)}))
.configure({defaultUploadUrl: 'https://nostr.build', defaultUploadType: 'nip96'}),
TopicExtension.extend({
addNodeView: () => SvelteNodeViewRenderer(GroupComposeTopic),
addProseMirrorPlugins() {
return [
createSuggestions({
char: '#',
name: 'topic',
editor: this.editor,
search: searchTopics,
select: (name: string, props: any) => props.command({name}),
allowCreate: true,
suggestionComponent: GroupComposeTopicSuggestion,
suggestionsComponent: GroupComposeSuggestions,
}),
]
},
link: false,
tweet: false,
youtube: false,
video: {defaultUploadUrl: 'https://nostr.build', defaultUploadType: 'nip96'},
image: {defaultUploadUrl: 'https://nostr.build', defaultUploadType: 'nip96'},
fileUpload: {
immediateUpload: false,
sign: (event: StampedEvent) => {
uploading = true
}),
FileUploadExtension.configure({
immediateUpload: false,
sign: (event: StampedEvent) => {
uploading = true
return $signer!.sign(event)
},
onComplete: () => {
uploading = false
sendMessage()
},
return $signer!.sign(event)
},
onComplete: () => {
uploading = false
sendMessage()
},
}),
createSuggestions('mention').configure({
char: '@',
search: searchProfiles,
select: (pubkey: string, props: any) => props.command({pubkey}),
suggestionComponent: GroupComposeProfileSuggestion,
suggestionsComponent: GroupComposeSuggestions,
}).extend({
addAttributes: () => ({pubkey: {default: null}}),
addNodeView: () => SvelteNodeViewRenderer(GroupComposeMention),
}),
createSuggestions('topic').configure({
char: '#',
search: searchTopics,
select: (name: string, props: any) => props.command({name}),
suggestionComponent: GroupComposeTopicSuggestion,
suggestionsComponent: GroupComposeSuggestions,
}).extend({
addAttributes: () => ({name: {default: null}}),
addNodeView: () => SvelteNodeViewRenderer(GroupComposeMention),
}),
],
content: '',

View File

@@ -11,6 +11,7 @@
export let select
export let component
export let loading = false
export let allowCreate = false
let index = 0
let element: Element
@@ -28,13 +29,20 @@
}
export const onKeyDown = (e: any) => {
if (e.code === "Enter") {
if (['Enter', 'Tab'].includes(e.code)) {
const value = items[index]
if (value) {
select(value)
return true
} else if (term && allowCreate) {
select(term)
return true
}
}
if (e.code === 'Space' && term && allowCreate) {
select(term)
return true
}
@@ -52,12 +60,20 @@
}
</script>
{#if items.length > 0}
{#if items.length > 0 || (term && allowCreate)}
<div
data-theme={$theme}
bind:this={element}
transition:slide|local={{duration: 100}}
class="mt-2 flex max-h-[350px] flex-col overflow-y-auto overflow-x-hidden shadow-xl">
{#if term && allowCreate}
<button
class="cursor-pointer px-4 py-2 text-left hover:bg-primary hover:text-primary-content transition-colors white-space-nowrap overflow-hidden text-ellipsis min-w-0"
on:mousedown|preventDefault
on:click|preventDefault={() => select(term)}>
Use "<svelte:component this={component} value={term} />"
</button>
{/if}
{#each items as value, i (value)}
<button
class="cursor-pointer px-4 py-2 text-left hover:bg-primary hover:text-primary-content transition-colors white-space-nowrap overflow-hidden text-ellipsis min-w-0"

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import type {NodeViewProps} from '@tiptap/core'
import {NodeViewWrapper} from 'svelte-tiptap'
export let node: NodeViewProps['node']
</script>
<NodeViewWrapper class="inline text-primary">
#<span class="underline">{node.attrs.name}</span>
</NodeViewWrapper>

View File

@@ -2,6 +2,4 @@
export let value
</script>
<div>
#{value}
</div>
#{value}

View File

@@ -2,136 +2,105 @@ import type {SvelteComponent, ComponentType} from 'svelte'
import type {Readable} from 'svelte/store'
import tippy, {type Instance} from 'tippy.js'
import {mergeAttributes, Node} from '@tiptap/core'
import type {Editor} from '@tiptap/core'
import {PluginKey} from '@tiptap/pm/state'
import Suggestion from '@tiptap/suggestion'
import type {Search} from '@lib/util'
export type SuggestionsOptions = {
char: string,
name: string,
editor: Editor,
search: Readable<Search<any, any>>
select: (value: any, props: any) => void
allowCreate?: boolean,
suggestionComponent: ComponentType
suggestionsComponent: ComponentType
}
export const createSuggestions = (name: string) =>
Node.create<SuggestionsOptions>({
name,
atom: true,
inline: true,
group: 'inline',
selectable: false,
addKeyboardShortcuts() {
export const createSuggestions = (options: SuggestionsOptions) =>
Suggestion({
char: options.char,
editor: options.editor,
pluginKey: new PluginKey(`suggest-${options.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: options.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[options.name]
return !!$from.parent.type.contentMatch.matchType(type)
},
render: () => {
let popover: Instance[]
let target: HTMLElement
let suggestions: SvelteComponent
const mapProps = (props: any) => ({
term: props.query,
search: options.search,
allowCreate: options.allowCreate,
component: options.suggestionComponent,
select: (value: string) => options.select(value, props),
})
return {
Backspace: () => this.editor.commands.command(({ tr, state }) => {
let isMention = false
const { selection } = state
const { empty, anchor } = selection
onStart: props => {
target = document.createElement("div")
if (!empty) {
return false
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true
tr.insertText('', pos, pos + node.nodeSize)
return false
}
popover = tippy('body', {
getReferenceClientRect: props.clientRect as any,
appendTo: document.body,
content: target,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})
return isMention
}),
suggestions = new options.suggestionsComponent({target, props: mapProps(props)})
},
onUpdate: props => {
suggestions.$set(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(suggestions.onKeyDown?.(props.event))
},
onExit: () => {
popover[0].destroy()
suggestions.$destroy()
},
}
},
addProseMirrorPlugins() {
return [
Suggestion({
pluginKey: new PluginKey(name),
editor: this.editor,
char: this.options.char,
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: this.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[this.name]
const allow = !!$from.parent.type.contentMatch.matchType(type)
return allow
},
render: () => {
let popover: Instance[]
let target: HTMLElement
let suggestions: SvelteComponent
const mapProps = (props: any) => ({
term: props.query,
search: this.options.search,
component: this.options.suggestionComponent,
select: (value: string) => this.options.select(value, props),
})
return {
onStart: props => {
target = document.createElement("div")
popover = tippy('body', {
getReferenceClientRect: props.clientRect as any,
appendTo: document.body,
content: target,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})
suggestions = new this.options.suggestionsComponent({target, props: mapProps(props)})
},
onUpdate: props => {
suggestions.$set(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(suggestions.onKeyDown?.(props.event))
},
onExit: () => {
popover[0].destroy()
suggestions.$destroy()
},
}
},
}),
]
},
})

View File

@@ -0,0 +1,81 @@
import {Node, nodePasteRule} from '@tiptap/core'
import type {Node as ProsemirrorNode} from '@tiptap/pm/model'
import type {MarkdownSerializerState} from 'prosemirror-markdown'
import {createPasteRuleMatch} from '@lib/tiptap/util'
export const TOPIC_REGEX = /(#[^\s]+)/g
export interface TopicAttributes {
name: string
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
topic: {
insertTopic: (options: { name: string }) => ReturnType
}
}
}
export const TopicExtension = Node.create({
atom: true,
name: 'topic',
group: 'inline',
inline: true,
selectable: true,
draggable: true,
priority: 1000,
addAttributes() {
return {
name: { default: null },
}
},
renderText(props) {
return "#" + props.node.attrs.name
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write(node.attrs.name)
},
parse: {},
},
}
},
addCommands() {
return {
insertTopic:
({ name }) =>
({ commands }) => {
return commands.insertContent(
{ type: this.name, attrs: { name } },
{
updateSelection: false,
},
)
},
}
},
addPasteRules() {
return [
nodePasteRule({
type: this.type,
getAttributes: (match) => match.data,
find: (text) => {
const matches = []
for (const match of text.matchAll(TOPIC_REGEX)) {
try {
matches.push(createPasteRuleMatch(match, { name: match[0] }))
} catch (e) {
continue
}
}
return matches
},
}),
]
},
})

View File

@@ -1,20 +1,4 @@
import type {JSONContent} from '@tiptap/core'
export * from '@lib/tiptap/util'
export {createSuggestions} from '@lib/tiptap/Suggestions'
export {TopicExtension} from '@lib/tiptap/TopicExtension'
export {LinkExtension} from '@lib/tiptap/LinkExtension'
export const findNodes = (json: JSONContent, type: string) => {
const results: JSONContent[] = []
for (const node of json.content || []) {
if (node.type === type) {
results.push(node)
}
for (const result of findNodes(node, type)) {
results.push(result)
}
}
return results
}

22
src/lib/tiptap/util.ts Normal file
View File

@@ -0,0 +1,22 @@
import type {JSONContent, PasteRuleMatch} from '@tiptap/core'
export const createPasteRuleMatch = <T extends Record<string, unknown>>(
match: RegExpMatchArray,
data: T,
): PasteRuleMatch => ({ index: match.index!, replaceWith: match[2], text: match[0], match, data })
export const findNodes = (json: JSONContent, type: string) => {
const results: JSONContent[] = []
for (const node of json.content || []) {
if (node.type === type) {
results.push(node)
}
for (const result of findNodes(node, type)) {
results.push(result)
}
}
return results
}

View File

@@ -60,7 +60,7 @@
</script>
<div class="relative flex h-screen flex-col">
<div class="relative z-feature px-2 pt-4">
<div class="relative z-feature mx-2 pt-4 rounded-xl">
<div class="flex min-h-12 items-center gap-4 rounded-xl bg-base-100 px-4 shadow-xl">
<div class="flex items-center gap-2">
<Icon icon="hashtag" />
@@ -68,7 +68,7 @@
</div>
</div>
</div>
<div class="flex flex-grow flex-col-reverse overflow-auto">
<div class="flex flex-grow flex-col-reverse overflow-auto -mt-2 py-2">
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<div class="flex items-center gap-2 p-2 text-xs opacity-50">