Skip to content

Commit

Permalink
feat(schema): initial prefix slash implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
Novout committed May 31, 2023
1 parent 98f5514 commit 3abfc3d
Show file tree
Hide file tree
Showing 6 changed files with 483 additions and 231 deletions.
32 changes: 16 additions & 16 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,22 @@
},
"dependencies": {
"@headlessui/vue": "1.7.13",
"@milkdown/core": "7.2.1",
"@milkdown/ctx": "7.2.1",
"@milkdown/plugin-block": "7.2.1",
"@milkdown/plugin-clipboard": "7.2.1",
"@milkdown/plugin-cursor": "7.2.1",
"@milkdown/plugin-emoji": "7.2.1",
"@milkdown/plugin-history": "7.2.1",
"@milkdown/plugin-indent": "7.2.1",
"@milkdown/plugin-listener": "7.2.1",
"@milkdown/plugin-math": "7.2.1",
"@milkdown/plugin-tooltip": "7.2.1",
"@milkdown/plugin-trailing": "7.2.1",
"@milkdown/plugin-upload": "7.2.1",
"@milkdown/preset-commonmark": "7.2.1",
"@milkdown/prose": "7.2.1",
"@milkdown/transformer": "7.2.1",
"@milkdown/core": "7.2.2",
"@milkdown/ctx": "7.2.2",
"@milkdown/plugin-block": "7.2.2",
"@milkdown/plugin-clipboard": "7.2.2",
"@milkdown/plugin-cursor": "7.2.2",
"@milkdown/plugin-emoji": "7.2.2",
"@milkdown/plugin-history": "7.2.2",
"@milkdown/plugin-indent": "7.2.2",
"@milkdown/plugin-listener": "7.2.2",
"@milkdown/plugin-math": "7.2.2",
"@milkdown/plugin-tooltip": "7.2.2",
"@milkdown/plugin-trailing": "7.2.2",
"@milkdown/plugin-upload": "7.2.2",
"@milkdown/preset-commonmark": "7.2.2",
"@milkdown/prose": "7.2.2",
"@milkdown/transformer": "7.2.2",
"@sentry/tracing": "7.14.2",
"@sentry/vue": "7.14.2",
"@supabase/supabase-js": "2.21.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/use/storage/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export const useStorage = () => {
id: schemaAnnotationsId,
type: 'default',
name: t('editor.schemas.create.nameItem'),
prefix: '#',
prefix: '/',
customIcon: '📁',
folders: [] as ProjectStateSchemaFolder[],
} as ProjectStateSchema
Expand Down Expand Up @@ -238,7 +238,7 @@ export const useStorage = () => {
id: schemaCharactersId,
type: 'characters',
name: t('editor.schemas.types.characters.target'),
prefix: '#',
prefix: '/',
customIcon: '🐉',
folders: [] as ProjectStateSchemaFolder[],
} as ProjectStateSchema
Expand Down
46 changes: 46 additions & 0 deletions packages/plugin-schemas/src/plugins/slash/slash-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/* Copyright 2021, Milkdown by Mirone. */
import type { SliceType } from '@milkdown/ctx'
import type { PluginSpec } from '@milkdown/prose/state'
import { Plugin, PluginKey } from '@milkdown/prose/state'
import type { $Ctx, $Prose } from '@milkdown/utils'
import { $ctx, $prose } from '@milkdown/utils'

/// @internal
export type SlashPluginSpecId<Id extends string> = `${Id}_SLASH_SPEC`

/// @internal
export type SlashPlugin<Id extends string, State = any> = [
$Ctx<PluginSpec<State>, SlashPluginSpecId<Id>>,
$Prose
] & {
key: SliceType<PluginSpec<State>, SlashPluginSpecId<Id>>
pluginKey: $Prose['key']
}

/// Create a slash plugin with a unique id.
export const slashFactory = <Id extends string, State = any>(id: Id) => {
const slashSpec = $ctx<PluginSpec<State>, SlashPluginSpecId<Id>>(
{},
`${id}_SLASH_SPEC`
)
const slashPlugin = $prose((ctx) => {
const spec = ctx.get(slashSpec.key)
return new Plugin({
key: new PluginKey(`${id}_SLASH`),
...spec,
})
})
const result = [slashSpec, slashPlugin] as SlashPlugin<Id>
result.key = slashSpec.key
result.pluginKey = slashPlugin.key
slashSpec.meta = {
package: '@milkdown/plugin-slash',
displayName: `Ctx<slashSpec>|${id}`,
}
slashPlugin.meta = {
package: '@milkdown/plugin-slash',
displayName: `Prose<slash>|${id}`,
}

return result
}
176 changes: 176 additions & 0 deletions packages/plugin-schemas/src/plugins/slash/slash-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Wrapper of original milkdown-plugin-slash
/* Copyright 2021, Milkdown by Mirone. */
import { findParentNode, posToDOMRect } from '@milkdown/prose'
import type { EditorState } from '@milkdown/prose/state'
import type { Node } from '@milkdown/prose/model'
import { TextSelection } from '@milkdown/prose/state'
import type { EditorView } from '@milkdown/prose/view'
import debounce from 'lodash.debounce'
import type { Instance, Props } from 'tippy.js'
import tippy from 'tippy.js'

/// Options for slash provider.
export type SlashProviderOptions = {
/// The slash content.
content: HTMLElement
/// The options for creating [tippy.js](https://atomiks.github.io/tippyjs/) instance.
tippyOptions?: Partial<Props>
/// The debounce time for updating slash, 200ms by default.
debounce?: number
/// The function to determine whether the tooltip should be shown.
shouldShow?: (view: EditorView, prevState?: EditorState) => boolean

prefix?: string[]

marks?: { prefix: string; links: { name: string; id: string }[] }[]

markActive?: { prefix: string; links: { name: string; id: string }[] }
}

/// A provider for creating slash.
export class SlashProvider {
/// The root element of the slash.
element: HTMLElement

/// @internal
#tippy: Instance | undefined

/// @internal
#tippyOptions: Partial<Props>

/// @internal
#debounce: number

/// @internal
#marks: { prefix: string; links: { name: string; id: string }[] }[]

/// @internal
#markActive?: { prefix: string; links: { name: string; id: string }[] }

/// @internal
#shouldShow: (view: EditorView, prevState?: EditorState) => boolean

constructor(options: SlashProviderOptions) {
this.element = options.content
this.#tippyOptions = options.tippyOptions ?? {}
this.#debounce = options.debounce ?? 100
this.#shouldShow = options.shouldShow ?? this.#_shouldShow
this.#marks = options.marks ?? []
this.#markActive = options.marks ? options.marks[0] : undefined
}

/// @internal
#onUpdate = (view: EditorView, prevState?: EditorState): void => {
const { state, composing } = view
const { selection, doc } = state
const { ranges } = selection
const from = Math.min(...ranges.map((range) => range.$from.pos))
const to = Math.max(...ranges.map((range) => range.$to.pos))
const isSame =
prevState && prevState.doc.eq(doc) && prevState.selection.eq(selection)

this.#tippy ??= tippy(view.dom, {
trigger: 'manual',
placement: 'bottom-start',
interactive: true,
...this.#tippyOptions,
content: this.element,
})

if (composing || isSame) return

if (!this.#shouldShow(view, prevState)) {
this.hide()
return
}

this.#tippy.setProps({
getReferenceClientRect: () => posToDOMRect(view, from, to),
})

this.show()
}

/// @internal
#_shouldShow(view: EditorView): boolean {
const currentTextBlockContent = this.getContent(view)

if (!currentTextBlockContent) return false

const mark = this.#marks.find(
(item) => currentTextBlockContent.at(-1) === item.prefix
)

if (mark) {
this.#markActive = this.#marks.find((mark) => mark.prefix === mark.prefix)
}

return !!mark
}

/// Update provider state by editor view.
update = (view: EditorView, prevState?: EditorState): void => {
const updater = debounce(this.#onUpdate, this.#debounce)

updater(view, prevState)
}

/// Get the content of the current text block.
/// Pass the `matchNode` function to determine whether the current node should be matched, by default, it will match the paragraph node.
getContent = (
view: EditorView,
matchNode: (node: Node) => boolean = (node) =>
node.type.name === 'paragraph'
): string | undefined => {
const { selection } = view.state
const { empty } = selection
const isTextBlock = view.state.selection instanceof TextSelection

const isSlashChildren = this.element.contains(document.activeElement)

const notHasFocus = !view.hasFocus() && !isSlashChildren

const isReadonly = !view.editable

const paragraph = findParentNode(matchNode)(view.state.selection)

const isNotInParagraph = !paragraph

if (notHasFocus || isReadonly || !empty || !isTextBlock || isNotInParagraph)
return

return paragraph.node.textContent
}

/// Destroy the slash.
destroy = () => {
this.#tippy?.destroy()
}

/// Show the slash.
show = () => {
this.element.innerHTML =
this.#markActive?.links.reduce((acc, item) => {
return (acc += `<div id=\"${
item.id
}\" class=\"flex flex-col gap-2 bg-theme-background-3 p-2\">${
this.#markActive?.prefix ?? ''
} ${item.name}</div>`)
}, '<div class="bg-theme-background-2 wb-text w-full p-2 cursor-pointer min-w-20">') ??
'<div>'
this.element.innerHTML = this.element.innerHTML += '</div>'
this.element.onclick = (e) => {
this.hide()
}

this.#tippy?.show()
}

/// Hide the slash.
hide = () => {
this.#tippy?.hide()
}

/// Get the [tippy.js](https://atomiks.github.io/tippyjs/) instance.
getInstance = () => this.#tippy
}
58 changes: 52 additions & 6 deletions packages/plugin-schemas/src/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ import { block } from '@milkdown/plugin-block'
import { history } from '@milkdown/plugin-history'
import { cursor } from '@milkdown/plugin-cursor'
import { trailing } from '@milkdown/plugin-trailing'
import { emoji } from '@milkdown/plugin-emoji'
import { upload } from '@milkdown/plugin-upload'
import { commonmark } from '@milkdown/preset-commonmark'
import { ID } from 'better-write-types'
import { nextTick } from 'vue-demi'
import { ProjectStateSchemaCreate } from 'better-write-types'
import { slashFactory } from './plugins/slash/slash-plugin'
import { SlashProvider } from './plugins/slash/slash-provider'

export const PluginSchemasSet = (
emitter: PluginTypes.PluginEmitter,
Expand Down Expand Up @@ -204,12 +205,57 @@ export const PluginSchemasSet = (

await nextTick

const slash = slashFactory('prefix')
const marks: { prefix: string; links: { name: string; id: string }[] }[] =
[]

stores.PROJECT.schemas.forEach((schema: ProjectStateSchema) => {
const mark = {
prefix: schema.prefix,
links: [],
} as { prefix: string; links: { name: string; id: string }[] }

schema.folders.forEach((folder) => {
folder.files.forEach((file) => {
mark.links.push({
id: file.id,
name: `${folder.folderName}/${file.fileName}`,
})
})
})

marks.push(mark)
})

const editor = await Editor.make()
.config((ctx) => {
const el = document.querySelector('#bw-wysiwyg')

ctx.set(rootCtx, el)

const slashPluginView = (view: any) => {
const content = document.createElement('div')

const provider = new SlashProvider({
content,
marks,
})

return {
update: (updatedView: any, prevState: any) => {
provider.update(updatedView, prevState)
},
destroy: () => {
provider.destroy()
content.remove()
},
}
}

ctx.set(slash.key, {
view: slashPluginView,
})

if (Object.keys(file.milkdownData).length !== 0) {
ctx.set(defaultValueCtx, {
type: 'json',
Expand All @@ -224,12 +270,12 @@ export const PluginSchemasSet = (
)
}

const fn = hooks.vueuse.core.useDebounceFn((doc: any) => {
const saveContentFn = hooks.vueuse.core.useDebounceFn((doc: any) => {
setFile(file.id, doc.toJSON())
}, 300)

ctx.get(listenerCtx).updated((_, doc) => {
fn(doc)
ctx.get(listenerCtx).updated((ctx, doc) => {
saveContentFn(doc)
})

ctx.update(editorViewOptionsCtx, (prev) => ({
Expand All @@ -245,8 +291,8 @@ export const PluginSchemasSet = (
.use(history)
.use(cursor)
.use(trailing)
.use(emoji)
.use(upload)
//.use(slash)
.create()

const el = document.querySelector('#bw-wysiwyg')
Expand All @@ -267,7 +313,7 @@ export const PluginSchemasSet = (
}

On.externals().PluginSchemasStart(emitter, [
(obj: any) => {
(obj) => {
start(obj)
},
() => {},
Expand Down

0 comments on commit 3abfc3d

Please sign in to comment.