From 3833bf3877ebe423d5e518c1e2641c2012ed3c6b Mon Sep 17 00:00:00 2001 From: Ticruz Date: Fri, 29 Nov 2024 14:40:43 +0100 Subject: [PATCH 1/2] catch ascii in tags --- src/app/editor/TagExtension.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/editor/TagExtension.ts b/src/app/editor/TagExtension.ts index bfba92c73..2d5818a16 100644 --- a/src/app/editor/TagExtension.ts +++ b/src/app/editor/TagExtension.ts @@ -6,8 +6,8 @@ export interface TagAttributes { tag: string } -const REGEX_TAG_PASTE = /(#[^\W]+)(?=\s) /g -const REGEX_TAG_INPUT = /(#[^\W]+)(?=\s) /g +const REGEX_TAG_PASTE = /(#\p{L}[\p{L}\p{N}_]*)(?=\s)/gu +const REGEX_TAG_INPUT = /(#\p{L}[\p{L}\p{N}_]*)(?=\s)/gu export const TagExtension = Mark.create({ atom: true, @@ -61,7 +61,7 @@ export const TagExtension = Mark.create({ { find: text => { const match = last(Array.from(text.matchAll(REGEX_TAG_INPUT))) - if (match && text.length === match.index + match[0].length) { + if (match && text.length === match.index + match[0].length + 1) { return { index: match?.index, text: match[0], @@ -71,7 +71,12 @@ export const TagExtension = Mark.create({ return null }, handler: ({state, range, match}) => { - state.tr.addMark(range.from, range.to, this.type.create(match.data)).insertText(" ") + const newTr = state.tr.addMark(range.from - 1, range.to, this.type.create(match.data)) + if (last(match.input) === "\n") { + newTr.split(range.to) + } else { + newTr.insertText(" ") + } }, }, ] From 6820195bbeffd7477231af9b6cad32d04ee631e5 Mon Sep 17 00:00:00 2001 From: Ticruz Date: Mon, 2 Dec 2024 16:15:57 +0100 Subject: [PATCH 2/2] use suggestions for hashtags --- src/app/editor/Hashtag.svelte | 7 ++ src/app/editor/Hashtag.ts | 208 +++++++++++++++++++++++++++++++++ src/app/editor/TagExtension.ts | 84 ------------- src/app/editor/index.ts | 22 ++-- 4 files changed, 226 insertions(+), 95 deletions(-) create mode 100644 src/app/editor/Hashtag.svelte create mode 100644 src/app/editor/Hashtag.ts delete mode 100644 src/app/editor/TagExtension.ts diff --git a/src/app/editor/Hashtag.svelte b/src/app/editor/Hashtag.svelte new file mode 100644 index 000000000..f6a598913 --- /dev/null +++ b/src/app/editor/Hashtag.svelte @@ -0,0 +1,7 @@ + + + + {value} + diff --git a/src/app/editor/Hashtag.ts b/src/app/editor/Hashtag.ts new file mode 100644 index 000000000..02ee56e47 --- /dev/null +++ b/src/app/editor/Hashtag.ts @@ -0,0 +1,208 @@ +import {Node, mergeAttributes} from "@tiptap/core" +import Suggestion, {type SuggestionOptions} from "@tiptap/suggestion" +import {topicSearch} from "@welshman/app" +import {PluginKey} from "prosemirror-state" +import type {SvelteComponent} from "svelte" +import tippy, {type Instance} from "tippy.js" +import Suggestions from "src/app/editor/Suggestions.svelte" +import HashtagComponent from "src/app/editor/Hashtag.svelte" + +export type HashtagOptions = { + HTMLAttributes: Record + suggestion: Omit +} + +export const HashtagPluginKey = new PluginKey("hashtag") + +export const Hashtag = Node.create({ + name: "tag", + + addOptions: () => { + return { + HTMLAttributes: {"data-type": "tag"}, + suggestion: { + char: "#", + pluginKey: HashtagPluginKey, + 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: "tag", + attrs: props, + }, + { + type: "text", + text: " ", + }, + ]) + .run() + }, + allow: ({editor, range}) => { + return true + }, + render() { + let popover: Instance[] + let suggestions: SvelteComponent + const mapProps = (props: any) => ({ + term: props.query, + search: topicSearch, + component: HashtagComponent, + allowCreate: true, + select: (value: string) => { + props.command({label: value}) + }, + }) + + return { + onStart: props => { + const target = document.createElement("div") + 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() + suggestions = new Suggestions({target, props: mapProps(props)}) + }, + onUpdate: props => { + if (props.query) { + popover[0].show() + } else { + popover[0].hide() + } + 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() + }, + } + }, + }, + } + }, + + group: "inline", + + inline: true, + + selectable: false, + + atom: true, + + addAttributes() { + return { + id: { + default: null, + parseHTML: element => element.getAttribute("data-id"), + renderHTML: attributes => { + if (!attributes.id) { + return {} + } + + return { + "data-id": attributes.id, + } + }, + }, + + label: { + default: null, + parseHTML: element => element.getAttribute("data-label"), + renderHTML: attributes => { + if (!attributes.label) { + return {} + } + + return { + "data-label": attributes.label, + } + }, + }, + } + }, + + renderHTML({node, HTMLAttributes}) { + return [ + "a", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + "#" + node.attrs.label, + ] + }, + + renderText({node}) { + return "#" + node.attrs.label + }, + + parseHTML() { + return [ + { + tag: `a[data-type="${this.name}"]`, + }, + ] + }, + + addKeyboardShortcuts() { + return { + Backspace: () => + this.editor.commands.command(({tr, state}) => { + let isMention = false + const {selection} = state + const {empty, anchor} = selection + + if (!empty) { + return false + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isMention = true + tr.insertText(this.options.suggestion.char || "", pos, pos + node.nodeSize) + + return false + } + }) + + return isMention + }), + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) diff --git a/src/app/editor/TagExtension.ts b/src/app/editor/TagExtension.ts deleted file mode 100644 index 2d5818a16..000000000 --- a/src/app/editor/TagExtension.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type {InputRuleMatch} from "@tiptap/core" -import {Mark, markPasteRule, mergeAttributes} from "@tiptap/core" -import {last} from "ramda" - -export interface TagAttributes { - tag: string -} - -const REGEX_TAG_PASTE = /(#\p{L}[\p{L}\p{N}_]*)(?=\s)/gu -const REGEX_TAG_INPUT = /(#\p{L}[\p{L}\p{N}_]*)(?=\s)/gu - -export const TagExtension = Mark.create({ - atom: true, - name: "tag", - group: "inline", - inline: true, - selectable: true, - inclusive: false, - priority: 1000, - - addStorage() { - return { - markdown: { - serialize: { - open: "", - close: "", - mixable: false, - expelEnclosingWhitespace: true, - }, - parse: {}, - }, - } - }, - - renderHTML(p) { - return ["a", mergeAttributes(p.HTMLAttributes, {"data-type": this.name})] - }, - - parseHTML() { - return [{tag: `a[data-type="${this.name}"]`}] - }, - - addAttributes() { - return { - tag: {default: null}, - } - }, - - addPasteRules() { - return [ - markPasteRule({ - find: REGEX_TAG_PASTE, - getAttributes: match => ({tag: match[0]}), - type: this.type, - }), - ] - }, - - addInputRules() { - return [ - { - find: text => { - const match = last(Array.from(text.matchAll(REGEX_TAG_INPUT))) - if (match && text.length === match.index + match[0].length + 1) { - return { - index: match?.index, - text: match[0], - data: {tag: match[1]}, - } as InputRuleMatch - } - return null - }, - handler: ({state, range, match}) => { - const newTr = state.tr.addMark(range.from - 1, range.to, this.type.create(match.data)) - if (last(match.input) === "\n") { - newTr.split(range.to) - } else { - newTr.insertText(" ") - } - }, - }, - ] - }, -}) diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index 29b3685da..25ece78c6 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -22,17 +22,17 @@ import {ctx} from "@welshman/lib" import type {StampedEvent} from "@welshman/util" import {signer, profileSearch, RelayMode} from "@welshman/app" import {createSuggestions} from "./Suggestions" -import EditMention from "./EditMention.svelte" -import EditEvent from "./EditEvent.svelte" -import EditBolt11 from "./EditBolt11.svelte" -import EditMedia from "./EditMedia.svelte" -import Suggestions from "./Suggestions.svelte" -import SuggestionProfile from "./SuggestionProfile.svelte" -import {asInline} from "./util" -import {WordCount} from "./wordcounts" -import {FileUploadExtension} from "./FileUpload" +import EditMention from "src/app/editor/EditMention.svelte" +import EditEvent from "src/app/editor/EditEvent.svelte" +import EditBolt11 from "src/app/editor/EditBolt11.svelte" +import EditMedia from "src/app/editor/EditMedia.svelte" +import Suggestions from "src/app/editor/Suggestions.svelte" +import SuggestionProfile from "src/app/editor/SuggestionProfile.svelte" +import {asInline} from "src/app/editor/util" +import {WordCount} from "src/app/editor/wordcounts" +import {FileUploadExtension} from "src/app/editor/FileUpload" import {getSetting} from "src/engine" -import {TagExtension} from "./TagExtension" +import {Hashtag} from "src/app/editor/Hashtag" export {createSuggestions, EditMention, EditEvent, EditBolt11, EditMedia, Suggestions} export * from "./util" @@ -81,7 +81,7 @@ export const getEditorOptions = ({ History, Paragraph, Text, - TagExtension, + Hashtag, WordCount, Placeholder.configure({placeholder}), HardBreakExtension.extend({