import type { Editor } from '@tiptap/core' import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model' import type { SuggestionOptions } from '@tiptap/suggestion' import type { Thread } from '@wovin/core' import type { Accessor, Setter } from 'solid-js' import type { TiptapContent } from '../data/VMs/TypeMap' import { Extension, generateJSON, mergeAttributes, Node } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import Suggestion from '@tiptap/suggestion' import { EntityID_LENGTH, getHashID, query } from '@wovin/core' import { Logger } from 'besonders-logger' import { groupBy, last } from 'lodash-es' import stringify from 'safe-stable-stringify' import { Markdown } from 'tiptap-markdown' import { useAgent } from '../data/agent/AgentState' import { getRegexForTagInContext, serializeTiptapToVl, } from '../data/block-utils-nowin' import { RE_AT_TAG_WITHCONTEXT, RE_HASH_TAG_WITHCONTEXT, RE_PLUS_TAG_WITHCONTEXT, } from '../data/note3-regex-constants' import { useRawThread } from '../ui/reactive' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars export const baseExtensions = [ StarterKit.configure({ // hardBreak: false, // these don't make a lot of sense with it being already in a block tree listItem: false, orderedList: false, bulletList: false, // paragraph: { // HTMLAttributes: { // // class: 'min-h-[1rem]' // }, // }), // Configure Link (included in StarterKit v3) link: { // Docs: https://tiptap.dev/docs/editor/api/marks/link#settings // validate: href => /^httpsXXR?:\/\//.test(href), protocols: ['note3', 'mailto', 'ftp'], openOnClick: false, autolink: true, linkOnPaste: true, HTMLAttributes: { class: 'link-note3', }, }, // Underline is also included in StarterKit v3 with default config }), // Note: Underline and Link are now included in StarterKit v3, no need to add separately // HardBreak, // HardBreak.extend({ // addKeyboardShortcuts() { // return { // Enter: () => this.editor.commands.setHardBreak(), // } // }, // }), ] // Separate extensions for markdown parsing (includes bulletList/listItem) export const markdownExtensions = [ StarterKit.configure({ // Configure Link with custom settings (included in StarterKit v3) link: { protocols: ['note3', 'mailto', 'ftp'], openOnClick: false, autolink: true, linkOnPaste: true, HTMLAttributes: { class: 'link-note3', }, }, // Underline is included in StarterKit v3 with default config }), Markdown.configure({ bulletListMarker: '-', tightLists: true, }), ] export function htmlToTiptap(html: string): TiptapContent { return generateJSON(html, baseExtensions) } export function htmlToSerializedTiptap(html: string): string { return serializeTiptapToVl(htmlToTiptap(html)) } // type PasteHandler = (view: Editor['view'], event: ClipboardEvent, slice: Slice) => boolean | void // type TipTapPasteHandler = (props: { // state: Editor['state'] // range: Range // match: ExtendedRegExpMatchArray // commands: SingleCommands // chain: () => ChainedCommands // can: () => CanCommands // }) => boolean // export const CustomStackedPasteHandler = function(orderedHandlers: PasteHandler[]) { // DEBUG('creating CustomStackedPasteHandler', { orderedHandlers }) // return function CustomStackedPasteHandler(view, event, slice) { // let isHandled = false // for (const eachHandler of orderedHandlers) { // VERBOSE('stackedPaste calling', { eachHandler, with: { view, event, slice } }) // isHandled = !!(await eachHandler(view, event, slice)) // } // return isHandled // } // } export const SelectionDetectLinkHandler = function SelectionDetectLinkHandler( selectionLink: Accessor<{ href: string } | null>, setSelectionLink: Setter<{ href: string } | null>, ) { return Extension.create({ name: 'SelectionDetectLinkHandler', // @ts-expect-error broken types upstream onSelectionUpdate({ editor }) { const { state: _state, state: { selection } } = editor const href = editor.getAttributes('link')?.href if (!selection.empty && !!href) { setSelectionLink({ href }) && DEBUG('setting selection', selection, { editor, href }) } else if (selectionLink()) { setSelectionLink(null) || VERBOSE('unsetting isSelectionLinked') } }, }) } const HASH_DEFAULT_COLOR = '#4a4a4a' // '#bae6fd' const PLUS_DEFAULT_COLOR = '#a7ead0' const AT_DEFAULT_COLOR = '#beb8db' // <= less saturated <= '#b4a9ea' export const tagDefaultColorMap = { '#': HASH_DEFAULT_COLOR, '+': PLUS_DEFAULT_COLOR, '@': AT_DEFAULT_COLOR, } export function getContrastHighGreyForBg(color) { if (!color || color === undefined) { return null } // Removing # from the given hex color code const rgb = color.replace('#', '') // Separate the colors const red = Number.parseInt(rgb.substring(0, 2), 16) const green = Number.parseInt(rgb.substring(2, 4), 16) const blue = Number.parseInt(rgb.substring(4, 6), 16) // Calculate brightness value const brightness = Math.round(((red * 299) + (green * 587) + (blue * 114)) / 1000) // Return light or dark grey based on the brightness return brightness > 125 ? '#222' : '#ddd' } export function getStyleRulesForTag(ds: Thread, tagStringWithPrefix: string) { const tagID = getHashID(tagStringWithPrefix, EntityID_LENGTH) const prefix = tagID.slice(0, 1) const defaultTextCol = tagDefaultColorMap[prefix] const { text, bg } = getAllStyleAttr(ds, tagID) const bgRule = bg ? ` padding: 0.15em 0.25em 0.25em 0.25em; border-radius: 0.125rem; background-color: ${bg}; ` : '' // TODO set const textColor = text || (bg ? getContrastHighGreyForBg(bg) : defaultTextCol) const colorFromLogsRule = ` color: ${textColor}; ` return colorFromLogsRule + bgRule } function getAllStyleAttr(ds: Thread, tagID: string) { const styleEntityQuery = query(ds, [{ en: tagID, at: att => att.startsWith('style/') }]) const styleLogs = styleEntityQuery.isEmpty ? [] : styleEntityQuery.threadOfAllTrails.applogs const { 'style/text-color': textLogs = [], 'style/bg-color': bgLogs = [] } = groupBy(styleLogs, 'at') const text = textLogs.findLast(({ ag }) => ag === useAgent().ag)?.vl ?? last(textLogs)?.vl const bg = bgLogs.findLast(({ ag }) => ag === useAgent().ag)?.vl ?? last(bgLogs)?.vl return { bg, text } } type WordInfoT = { word: string, st: number, ed: number }[] function getWordInfo({ editor }: { editor: Editor }) { const text = editor.getText() const textLength = text.length const currentPos = editor.state.selection.anchor - 1 const words = getWordMap(text) const currentWordInfo = words.find(eachWord => currentPos >= eachWord.st && currentPos <= eachWord.ed) || { word: '', st: 0, ed: 0 } const currentWordSpan: { from: number, to: number } = { from: currentWordInfo.st + 1, to: currentWordInfo.ed + 1 } const returnObject = { words, currentPos, currentWordInfo, currentWordSpan } VERBOSE({ returnObject, editor, focused: editor.isFocused, text, textLength }) return returnObject } // https://tiptap.dev/docs/editor/guide/typescript#command-type declare module '@tiptap/core' { interface Commands { wordInfo: { /** * returns { words, currentPos, currentWordInfo, currentWordSpan } for the Editor */ getWordInfo: () => ReturnType } } } function getWordMap(text) { let nextStart = 0 return text.split(' ').map((eachWord) => { const retObj = { word: eachWord, st: nextStart, ed: eachWord.length + nextStart, } nextStart = retObj.ed + 1 // include the space return retObj }) as { word: string, st: number, ed: number }[] } export const WordInfoExtension = Extension.create({ name: 'wordInfo', // @ts-expect-error custom command return signature addCommands() { return { getWordInfo: () => getWordInfo, } }, }) export const TagHighlightExtension = Extension.create({ name: 'tagHighlight', addProseMirrorPlugins() { const rawDS = useRawThread() return [ new Plugin({ props: { decorations(state) { const decorations = [] state.doc.descendants((node, pos) => { if (!node.isText) return for ( const [symbol, regex] of [ ['#', new RegExp(RE_HASH_TAG_WITHCONTEXT.source, 'g')] as const, // (i) global regex needs to be recreated https://stackoverflow.com/questions/52163499/regex-not-working-if-the-regex-string-is-stored-in-a-separate-javascript-file/52163813#52163813 ['+', new RegExp(RE_PLUS_TAG_WITHCONTEXT.source, 'g')] as const, ['@', new RegExp(RE_AT_TAG_WITHCONTEXT.source, 'g')] as const, ] ) { for (const match of node.text.matchAll(regex)) { const tagWithSymbol = symbol + match[2] // if (tagWithSymbol === '+todo') continue // HACK don't overlap with todo extension const tagID = getHashID(tagWithSymbol, EntityID_LENGTH) const { text, bg } = getAllStyleAttr(rawDS, tagID) const textColor = text || (bg ? getContrastHighGreyForBg(bg) : tagDefaultColorMap[symbol]) const colorFromLogsRule = `color: ${textColor}; ` const bgRule = bg ? `padding: 0.15em 0.25em 0.25em 0.25em; background-color: ${bg}; ` : '' DEBUG({ tagID, match, text, textToUse: textColor, bg }) decorations.push( Decoration.inline( pos + match.index + match[1].length, // start + whitespace (to start after whitespace) pos + match.index + match[1].length + 1 + match[2].length, // + symbol + tag length { class: 'tag-in-block tag-col-invert rounded-sm', // cursor-pointer // border-1 border-solid rounded-sm p-0.5 m-0.5 border-1 border-solid style: ` text-underline-offset: 0.2em; text-decoration-thickness: 1px; ${ colorFromLogsRule }${bgRule}`, // onClick: (e) => { //TODO: onclick inline tags... not quite: https://discuss.prosemirror.net/t/how-to-handle-events-inside-decorations/1083/4 // if (e.button === 1 && e.ctrlKey) setSearch(tagWithSymbol) // }, }, ), ) } } }) if (!decorations.length) return return DecorationSet.create(state.doc, decorations) }, }, }), ] }, }) export function CreateTokenHidingExtension(tokenArray: readonly string[]) { return Extension.create({ name: `tagHide-${stringify(tokenArray)}`, addProseMirrorPlugins() { const rawDS = useRawThread() return [ new Plugin({ props: { decorations(state) { const decorations = [] state.doc.descendants((node, pos) => { if (!node.isText) return for ( const regex of tokenArray.map(eachToken => getRegexForTagInContext(eachToken, 'g')) // [ // ['#', new RegExp(RE_HASH_TAG_WITHCONTEXT.source, 'g')] as const, // (i) global regex needs to be recreated https://stackoverflow.com/questions/52163499/regex-not-working-if-the-regex-string-is-stored-in-a-separate-javascript-file/52163813#52163813 // ['+', new RegExp(RE_PLUS_TAG_WITHCONTEXT.source, 'g')] as const, // ['@', new RegExp(RE_AT_TAG_WITHCONTEXT.source, 'g')] as const, // ] ) { VERBOSE({ text: node.text, regex }) for (const match of node.text.matchAll(regex)) { // const tagWithSymbol = symbol + match[2] // const tagID = getHashID(tagWithSymbol, EntityID_LENGTH) // const { text, bg } = getAllStyleAttr(rawDS, tagID) // const textColor = text || (bg ? getContrastHighGreyForBg(bg) : tagDefaultColorMap[symbol]) // const colorFromLogsRule = `color: ${textColor}; ` // const bgRule = bg ? `padding: 0.15em 0.25em 0.25em 0.25em; background-color: ${bg}; ` : '' DEBUG({ regex, match }) decorations.push( Decoration.inline( pos + match.index + match[1].length, // start + whitespace (to start after whitespace) pos + match.index + match[1].length + 1 + match[2].length, // + symbol + tag length { class: 'hidden', // cursor-pointer // border-1 border-solid rounded-sm p-0.5 m-0.5 border-1 border-solid // style: 'text-underline-offset: 0.2em; text-decoration-thickness: 1px; ' + colorFromLogsRule + bgRule, // onClick: (e) => { //TODO: onclick inline tags... not quite: https://discuss.prosemirror.net/t/how-to-handle-events-inside-decorations/1083/4 // if (e.button === 1 && e.ctrlKey) setSearch(tagWithSymbol) // }, }, ), ) } } }) return DecorationSet.create(state.doc, decorations) }, }, }), ] }, }) } // wip AutoComplete adapted from Mention https://raw.githubusercontent.com/ueberdosis/tiptap/main/packages/extension-mention/src/mention.ts export interface AutoCompleteOptions { HTMLAttributes: Record /** @deprecated use renderText and renderHTML instead */ renderLabel?: (props: { options: AutoCompleteOptions, node: ProseMirrorNode }) => string renderText: (props: { options: AutoCompleteOptions, node: ProseMirrorNode }) => string renderHTML: (props: { options: AutoCompleteOptions, node: ProseMirrorNode }) => DOMOutputSpec deleteTriggerWithBackspace: boolean suggestion: Omit } export const AutoCompletePluginKey = new PluginKey('autocomplete') export const AutoComplete = Node.create({ name: 'autocomplete', addOptions() { return { HTMLAttributes: {}, renderText({ options, node }) { return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` }, deleteTriggerWithBackspace: false, renderHTML({ options, node }) { return [ 'span', mergeAttributes(this.HTMLAttributes, options.HTMLAttributes), `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, ] }, suggestion: { char: '@', pluginKey: AutoCompletePluginKey, 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 }, }, } }, 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, } }, }, } }, parseHTML() { return [ { tag: `span[data-type="${this.name}"]`, }, ] }, renderHTML({ node, HTMLAttributes }) { if (this.options.renderLabel !== undefined) { console.warn('renderLabel is deprecated use renderText and renderHTML instead') return [ 'span', mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), this.options.renderLabel({ options: this.options, node, }), ] } const mergedOptions = { ...this.options } mergedOptions.HTMLAttributes = mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes) const html = this.options.renderHTML({ options: mergedOptions, node, }) if (typeof html === 'string') { return [ 'span', mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), html, ] } return html }, renderText({ node }) { if (this.options.renderLabel !== undefined) { console.warn('renderLabel is deprecated use renderText and renderHTML instead') return this.options.renderLabel({ options: this.options, node, }) } return this.options.renderText({ options: this.options, node, }) }, addKeyboardShortcuts() { return { Backspace: () => this.editor.commands.command(({ tr, state }) => { let isAutoComplete = 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) { isAutoComplete = true tr.insertText( this.options.deleteTriggerWithBackspace ? '' : this.options.suggestion.char || '', pos, pos + node.nodeSize, ) return false } }) return isAutoComplete }), } }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ] }, })