forked from coracle/flotilla
Re-write suggestions
This commit is contained in:
@@ -175,7 +175,8 @@
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
<p class="row-2 text-lg text-error">
|
||||
<Icon icon="danger" />
|
||||
{missingInboxes.length} {missingInboxes.length > 1 ? 'inboxes are' : 'inbox is'} not configured.
|
||||
{missingInboxes.length}
|
||||
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
|
||||
</p>
|
||||
<p>
|
||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-4
@@ -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
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
export let value
|
||||
</script>
|
||||
|
||||
{value}
|
||||
@@ -0,0 +1,84 @@
|
||||
<svelte:options accessors />
|
||||
|
||||
<script lang="ts">
|
||||
import {fly} from "svelte/transition"
|
||||
import {throttle, clamp} from "@welshman/lib"
|
||||
|
||||
export let term
|
||||
export let search
|
||||
export let select
|
||||
export let component
|
||||
export let allowCreate = false
|
||||
|
||||
let index = 0
|
||||
let element: Element
|
||||
let items: string[] = []
|
||||
|
||||
$: populateItems(term)
|
||||
|
||||
const populateItems = throttle(300, term => {
|
||||
items = $search(term).slice(0, 5)
|
||||
})
|
||||
|
||||
const setIndex = (newIndex: number, block: any) => {
|
||||
index = clamp([0, items.length - 1], newIndex)
|
||||
}
|
||||
|
||||
export const onKeyDown = (e: any) => {
|
||||
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
|
||||
}
|
||||
|
||||
if (e.code === "ArrowUp") {
|
||||
setIndex(index - 1, "start")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (e.code === "ArrowDown") {
|
||||
setIndex(index + 1, "start")
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if term}
|
||||
<div bind:this={element} transition:fly|local={{duration: 200}} class="tiptap-suggestions">
|
||||
<div class="tiptap-suggestions__content">
|
||||
{#if term && allowCreate && !items.includes(term)}
|
||||
<button
|
||||
class="tiptap-suggestions__create"
|
||||
on:mousedown|preventDefault|stopPropagation
|
||||
on:click|preventDefault|stopPropagation={() => select(term)}>
|
||||
Use "<svelte:component this={component} value={term} />"
|
||||
</button>
|
||||
{/if}
|
||||
{#each items as value, i (value)}
|
||||
<button
|
||||
class="tiptap-suggestions__item"
|
||||
class:tiptap-suggestions__selected={index === i}
|
||||
on:mousedown|preventDefault|stopPropagation
|
||||
on:click|preventDefault|stopPropagation={() => select(value)}>
|
||||
<svelte:component this={component} {value} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if items.length === 0}
|
||||
<div class="tiptap-suggestions__empty">No results</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type {NodeViewProps} from "@tiptap/core"
|
||||
import {NodeViewWrapper} from "svelte-tiptap"
|
||||
|
||||
export let node: NodeViewProps["node"]
|
||||
export let selected: NodeViewProps["selected"]
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper as="span">
|
||||
<button class="tiptap-object {selected ? 'tiptap-active' : ''}">
|
||||
{node.attrs.lnbc.slice(0, 16)}...
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import type {NodeViewProps} from "@tiptap/core"
|
||||
import {NodeViewWrapper} from "svelte-tiptap"
|
||||
|
||||
export let node: NodeViewProps["node"]
|
||||
export let selected: NodeViewProps["selected"]
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper as="span">
|
||||
<button class="tiptap-object {selected ? 'tiptap-active' : ''}">
|
||||
{fromNostrURI(node.attrs.bech32).slice(0, 16)}...
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type {NodeViewProps} from "@tiptap/core"
|
||||
import {NodeViewWrapper} from "svelte-tiptap"
|
||||
|
||||
export let node: NodeViewProps["node"]
|
||||
export let selected: NodeViewProps["selected"]
|
||||
|
||||
$: selectedClass = selected ? "tiptap-active" : ""
|
||||
$: uploadingClass = node.attrs.uploading ? "tiptap-uploading" : ""
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper as="span" class="tiptap-object {selectedClass} {uploadingClass}">
|
||||
{node.attrs.file?.name || node.attrs.src}
|
||||
</NodeViewWrapper>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type {NodeViewProps} from "@tiptap/core"
|
||||
import {NodeViewWrapper} from "svelte-tiptap"
|
||||
|
||||
export let node: NodeViewProps["node"]
|
||||
export let selected: NodeViewProps["selected"]
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper as="span">
|
||||
<button class="tiptap-object {selected ? 'tiptap-active' : ''}">
|
||||
@{node.attrs.bech32.slice(0, 16)}...
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
@@ -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"
|
||||
@@ -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<BreakOrSubmitOptions>({
|
||||
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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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<CodeOptions>({
|
||||
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(" ")
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -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<C = any, E = any> =
|
||||
| false
|
||||
| {
|
||||
extend?: Partial<E>
|
||||
config?: Partial<C>
|
||||
}
|
||||
|
||||
export type EmptyOptions = object
|
||||
|
||||
export type WelshmanExtensionOptions = {
|
||||
bolt11?: false
|
||||
breakOrSubmit?: ChildExtensionOptions<BreakOrSubmitOptions>
|
||||
codeInline?: ChildExtensionOptions<CodeInlineOptions>
|
||||
codeBlock?: ChildExtensionOptions<CodeBlockOptions>
|
||||
document?: false
|
||||
dropcursor?: ChildExtensionOptions<DropcursorOptions>
|
||||
fileUpload?: ChildExtensionOptions<FileUploadOptions>
|
||||
gapcursor?: false
|
||||
history?: ChildExtensionOptions<HistoryOptions>
|
||||
image?: ChildExtensionOptions<ImageOptions>
|
||||
link?: ChildExtensionOptions<LinkOptions>
|
||||
naddr?: ChildExtensionOptions<EmptyOptions>
|
||||
nevent?: ChildExtensionOptions<EmptyOptions>
|
||||
nprofile?: ChildExtensionOptions<EmptyOptions>
|
||||
nsecReject?: ChildExtensionOptions<NSecRejectOptions>
|
||||
paragraph?: ChildExtensionOptions<ParagraphOptions>
|
||||
placeholder?: ChildExtensionOptions<PlaceholderOptions>
|
||||
tag?: false
|
||||
text?: false
|
||||
video?: false
|
||||
wordCount?: false
|
||||
}
|
||||
|
||||
export interface WelshmanOptions extends NostrOptions {
|
||||
submit?: () => void
|
||||
sign?: (event: StampedEvent) => Promise<SignedEvent>
|
||||
defaultUploadUrl?: string
|
||||
defaultUploadType?: "nip96" | "blossom"
|
||||
extensions?: WelshmanExtensionOptions
|
||||
}
|
||||
|
||||
export const WelshmanExtension = NostrExtension.extend<WelshmanOptions>({
|
||||
// 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
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./BreakOrSubmit.js"
|
||||
export * from "./CodeInline.js"
|
||||
export * from "./Welshman.js"
|
||||
export * from "./WordCount.js"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import "./index.css"
|
||||
|
||||
export * from "./components/index.js"
|
||||
export * from "./extensions/index.js"
|
||||
export * from "./plugins/index.js"
|
||||
@@ -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<TippySuggestionOptions> & {
|
||||
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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./TippySuggestion.js"
|
||||
Reference in New Issue
Block a user