Skip to content

Commit 3abfc3d

Browse files
committed
feat(schema): initial prefix slash implementation
1 parent 98f5514 commit 3abfc3d

File tree

6 files changed

+483
-231
lines changed

6 files changed

+483
-231
lines changed

packages/app/package.json

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,22 @@
1717
},
1818
"dependencies": {
1919
"@headlessui/vue": "1.7.13",
20-
"@milkdown/core": "7.2.1",
21-
"@milkdown/ctx": "7.2.1",
22-
"@milkdown/plugin-block": "7.2.1",
23-
"@milkdown/plugin-clipboard": "7.2.1",
24-
"@milkdown/plugin-cursor": "7.2.1",
25-
"@milkdown/plugin-emoji": "7.2.1",
26-
"@milkdown/plugin-history": "7.2.1",
27-
"@milkdown/plugin-indent": "7.2.1",
28-
"@milkdown/plugin-listener": "7.2.1",
29-
"@milkdown/plugin-math": "7.2.1",
30-
"@milkdown/plugin-tooltip": "7.2.1",
31-
"@milkdown/plugin-trailing": "7.2.1",
32-
"@milkdown/plugin-upload": "7.2.1",
33-
"@milkdown/preset-commonmark": "7.2.1",
34-
"@milkdown/prose": "7.2.1",
35-
"@milkdown/transformer": "7.2.1",
20+
"@milkdown/core": "7.2.2",
21+
"@milkdown/ctx": "7.2.2",
22+
"@milkdown/plugin-block": "7.2.2",
23+
"@milkdown/plugin-clipboard": "7.2.2",
24+
"@milkdown/plugin-cursor": "7.2.2",
25+
"@milkdown/plugin-emoji": "7.2.2",
26+
"@milkdown/plugin-history": "7.2.2",
27+
"@milkdown/plugin-indent": "7.2.2",
28+
"@milkdown/plugin-listener": "7.2.2",
29+
"@milkdown/plugin-math": "7.2.2",
30+
"@milkdown/plugin-tooltip": "7.2.2",
31+
"@milkdown/plugin-trailing": "7.2.2",
32+
"@milkdown/plugin-upload": "7.2.2",
33+
"@milkdown/preset-commonmark": "7.2.2",
34+
"@milkdown/prose": "7.2.2",
35+
"@milkdown/transformer": "7.2.2",
3636
"@sentry/tracing": "7.14.2",
3737
"@sentry/vue": "7.14.2",
3838
"@supabase/supabase-js": "2.21.0",

packages/app/src/use/storage/storage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export const useStorage = () => {
201201
id: schemaAnnotationsId,
202202
type: 'default',
203203
name: t('editor.schemas.create.nameItem'),
204-
prefix: '#',
204+
prefix: '/',
205205
customIcon: '📁',
206206
folders: [] as ProjectStateSchemaFolder[],
207207
} as ProjectStateSchema
@@ -238,7 +238,7 @@ export const useStorage = () => {
238238
id: schemaCharactersId,
239239
type: 'characters',
240240
name: t('editor.schemas.types.characters.target'),
241-
prefix: '#',
241+
prefix: '/',
242242
customIcon: '🐉',
243243
folders: [] as ProjectStateSchemaFolder[],
244244
} as ProjectStateSchema
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/* Copyright 2021, Milkdown by Mirone. */
2+
import type { SliceType } from '@milkdown/ctx'
3+
import type { PluginSpec } from '@milkdown/prose/state'
4+
import { Plugin, PluginKey } from '@milkdown/prose/state'
5+
import type { $Ctx, $Prose } from '@milkdown/utils'
6+
import { $ctx, $prose } from '@milkdown/utils'
7+
8+
/// @internal
9+
export type SlashPluginSpecId<Id extends string> = `${Id}_SLASH_SPEC`
10+
11+
/// @internal
12+
export type SlashPlugin<Id extends string, State = any> = [
13+
$Ctx<PluginSpec<State>, SlashPluginSpecId<Id>>,
14+
$Prose
15+
] & {
16+
key: SliceType<PluginSpec<State>, SlashPluginSpecId<Id>>
17+
pluginKey: $Prose['key']
18+
}
19+
20+
/// Create a slash plugin with a unique id.
21+
export const slashFactory = <Id extends string, State = any>(id: Id) => {
22+
const slashSpec = $ctx<PluginSpec<State>, SlashPluginSpecId<Id>>(
23+
{},
24+
`${id}_SLASH_SPEC`
25+
)
26+
const slashPlugin = $prose((ctx) => {
27+
const spec = ctx.get(slashSpec.key)
28+
return new Plugin({
29+
key: new PluginKey(`${id}_SLASH`),
30+
...spec,
31+
})
32+
})
33+
const result = [slashSpec, slashPlugin] as SlashPlugin<Id>
34+
result.key = slashSpec.key
35+
result.pluginKey = slashPlugin.key
36+
slashSpec.meta = {
37+
package: '@milkdown/plugin-slash',
38+
displayName: `Ctx<slashSpec>|${id}`,
39+
}
40+
slashPlugin.meta = {
41+
package: '@milkdown/plugin-slash',
42+
displayName: `Prose<slash>|${id}`,
43+
}
44+
45+
return result
46+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Wrapper of original milkdown-plugin-slash
2+
/* Copyright 2021, Milkdown by Mirone. */
3+
import { findParentNode, posToDOMRect } from '@milkdown/prose'
4+
import type { EditorState } from '@milkdown/prose/state'
5+
import type { Node } from '@milkdown/prose/model'
6+
import { TextSelection } from '@milkdown/prose/state'
7+
import type { EditorView } from '@milkdown/prose/view'
8+
import debounce from 'lodash.debounce'
9+
import type { Instance, Props } from 'tippy.js'
10+
import tippy from 'tippy.js'
11+
12+
/// Options for slash provider.
13+
export type SlashProviderOptions = {
14+
/// The slash content.
15+
content: HTMLElement
16+
/// The options for creating [tippy.js](https://atomiks.github.io/tippyjs/) instance.
17+
tippyOptions?: Partial<Props>
18+
/// The debounce time for updating slash, 200ms by default.
19+
debounce?: number
20+
/// The function to determine whether the tooltip should be shown.
21+
shouldShow?: (view: EditorView, prevState?: EditorState) => boolean
22+
23+
prefix?: string[]
24+
25+
marks?: { prefix: string; links: { name: string; id: string }[] }[]
26+
27+
markActive?: { prefix: string; links: { name: string; id: string }[] }
28+
}
29+
30+
/// A provider for creating slash.
31+
export class SlashProvider {
32+
/// The root element of the slash.
33+
element: HTMLElement
34+
35+
/// @internal
36+
#tippy: Instance | undefined
37+
38+
/// @internal
39+
#tippyOptions: Partial<Props>
40+
41+
/// @internal
42+
#debounce: number
43+
44+
/// @internal
45+
#marks: { prefix: string; links: { name: string; id: string }[] }[]
46+
47+
/// @internal
48+
#markActive?: { prefix: string; links: { name: string; id: string }[] }
49+
50+
/// @internal
51+
#shouldShow: (view: EditorView, prevState?: EditorState) => boolean
52+
53+
constructor(options: SlashProviderOptions) {
54+
this.element = options.content
55+
this.#tippyOptions = options.tippyOptions ?? {}
56+
this.#debounce = options.debounce ?? 100
57+
this.#shouldShow = options.shouldShow ?? this.#_shouldShow
58+
this.#marks = options.marks ?? []
59+
this.#markActive = options.marks ? options.marks[0] : undefined
60+
}
61+
62+
/// @internal
63+
#onUpdate = (view: EditorView, prevState?: EditorState): void => {
64+
const { state, composing } = view
65+
const { selection, doc } = state
66+
const { ranges } = selection
67+
const from = Math.min(...ranges.map((range) => range.$from.pos))
68+
const to = Math.max(...ranges.map((range) => range.$to.pos))
69+
const isSame =
70+
prevState && prevState.doc.eq(doc) && prevState.selection.eq(selection)
71+
72+
this.#tippy ??= tippy(view.dom, {
73+
trigger: 'manual',
74+
placement: 'bottom-start',
75+
interactive: true,
76+
...this.#tippyOptions,
77+
content: this.element,
78+
})
79+
80+
if (composing || isSame) return
81+
82+
if (!this.#shouldShow(view, prevState)) {
83+
this.hide()
84+
return
85+
}
86+
87+
this.#tippy.setProps({
88+
getReferenceClientRect: () => posToDOMRect(view, from, to),
89+
})
90+
91+
this.show()
92+
}
93+
94+
/// @internal
95+
#_shouldShow(view: EditorView): boolean {
96+
const currentTextBlockContent = this.getContent(view)
97+
98+
if (!currentTextBlockContent) return false
99+
100+
const mark = this.#marks.find(
101+
(item) => currentTextBlockContent.at(-1) === item.prefix
102+
)
103+
104+
if (mark) {
105+
this.#markActive = this.#marks.find((mark) => mark.prefix === mark.prefix)
106+
}
107+
108+
return !!mark
109+
}
110+
111+
/// Update provider state by editor view.
112+
update = (view: EditorView, prevState?: EditorState): void => {
113+
const updater = debounce(this.#onUpdate, this.#debounce)
114+
115+
updater(view, prevState)
116+
}
117+
118+
/// Get the content of the current text block.
119+
/// Pass the `matchNode` function to determine whether the current node should be matched, by default, it will match the paragraph node.
120+
getContent = (
121+
view: EditorView,
122+
matchNode: (node: Node) => boolean = (node) =>
123+
node.type.name === 'paragraph'
124+
): string | undefined => {
125+
const { selection } = view.state
126+
const { empty } = selection
127+
const isTextBlock = view.state.selection instanceof TextSelection
128+
129+
const isSlashChildren = this.element.contains(document.activeElement)
130+
131+
const notHasFocus = !view.hasFocus() && !isSlashChildren
132+
133+
const isReadonly = !view.editable
134+
135+
const paragraph = findParentNode(matchNode)(view.state.selection)
136+
137+
const isNotInParagraph = !paragraph
138+
139+
if (notHasFocus || isReadonly || !empty || !isTextBlock || isNotInParagraph)
140+
return
141+
142+
return paragraph.node.textContent
143+
}
144+
145+
/// Destroy the slash.
146+
destroy = () => {
147+
this.#tippy?.destroy()
148+
}
149+
150+
/// Show the slash.
151+
show = () => {
152+
this.element.innerHTML =
153+
this.#markActive?.links.reduce((acc, item) => {
154+
return (acc += `<div id=\"${
155+
item.id
156+
}\" class=\"flex flex-col gap-2 bg-theme-background-3 p-2\">${
157+
this.#markActive?.prefix ?? ''
158+
} ${item.name}</div>`)
159+
}, '<div class="bg-theme-background-2 wb-text w-full p-2 cursor-pointer min-w-20">') ??
160+
'<div>'
161+
this.element.innerHTML = this.element.innerHTML += '</div>'
162+
this.element.onclick = (e) => {
163+
this.hide()
164+
}
165+
166+
this.#tippy?.show()
167+
}
168+
169+
/// Hide the slash.
170+
hide = () => {
171+
this.#tippy?.hide()
172+
}
173+
174+
/// Get the [tippy.js](https://atomiks.github.io/tippyjs/) instance.
175+
getInstance = () => this.#tippy
176+
}

packages/plugin-schemas/src/set.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ import { block } from '@milkdown/plugin-block'
1919
import { history } from '@milkdown/plugin-history'
2020
import { cursor } from '@milkdown/plugin-cursor'
2121
import { trailing } from '@milkdown/plugin-trailing'
22-
import { emoji } from '@milkdown/plugin-emoji'
2322
import { upload } from '@milkdown/plugin-upload'
2423
import { commonmark } from '@milkdown/preset-commonmark'
2524
import { ID } from 'better-write-types'
2625
import { nextTick } from 'vue-demi'
2726
import { ProjectStateSchemaCreate } from 'better-write-types'
27+
import { slashFactory } from './plugins/slash/slash-plugin'
28+
import { SlashProvider } from './plugins/slash/slash-provider'
2829

2930
export const PluginSchemasSet = (
3031
emitter: PluginTypes.PluginEmitter,
@@ -204,12 +205,57 @@ export const PluginSchemasSet = (
204205

205206
await nextTick
206207

208+
const slash = slashFactory('prefix')
209+
const marks: { prefix: string; links: { name: string; id: string }[] }[] =
210+
[]
211+
212+
stores.PROJECT.schemas.forEach((schema: ProjectStateSchema) => {
213+
const mark = {
214+
prefix: schema.prefix,
215+
links: [],
216+
} as { prefix: string; links: { name: string; id: string }[] }
217+
218+
schema.folders.forEach((folder) => {
219+
folder.files.forEach((file) => {
220+
mark.links.push({
221+
id: file.id,
222+
name: `${folder.folderName}/${file.fileName}`,
223+
})
224+
})
225+
})
226+
227+
marks.push(mark)
228+
})
229+
207230
const editor = await Editor.make()
208231
.config((ctx) => {
209232
const el = document.querySelector('#bw-wysiwyg')
210233

211234
ctx.set(rootCtx, el)
212235

236+
const slashPluginView = (view: any) => {
237+
const content = document.createElement('div')
238+
239+
const provider = new SlashProvider({
240+
content,
241+
marks,
242+
})
243+
244+
return {
245+
update: (updatedView: any, prevState: any) => {
246+
provider.update(updatedView, prevState)
247+
},
248+
destroy: () => {
249+
provider.destroy()
250+
content.remove()
251+
},
252+
}
253+
}
254+
255+
ctx.set(slash.key, {
256+
view: slashPluginView,
257+
})
258+
213259
if (Object.keys(file.milkdownData).length !== 0) {
214260
ctx.set(defaultValueCtx, {
215261
type: 'json',
@@ -224,12 +270,12 @@ export const PluginSchemasSet = (
224270
)
225271
}
226272

227-
const fn = hooks.vueuse.core.useDebounceFn((doc: any) => {
273+
const saveContentFn = hooks.vueuse.core.useDebounceFn((doc: any) => {
228274
setFile(file.id, doc.toJSON())
229275
}, 300)
230276

231-
ctx.get(listenerCtx).updated((_, doc) => {
232-
fn(doc)
277+
ctx.get(listenerCtx).updated((ctx, doc) => {
278+
saveContentFn(doc)
233279
})
234280

235281
ctx.update(editorViewOptionsCtx, (prev) => ({
@@ -245,8 +291,8 @@ export const PluginSchemasSet = (
245291
.use(history)
246292
.use(cursor)
247293
.use(trailing)
248-
.use(emoji)
249294
.use(upload)
295+
//.use(slash)
250296
.create()
251297

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

269315
On.externals().PluginSchemasStart(emitter, [
270-
(obj: any) => {
316+
(obj) => {
271317
start(obj)
272318
},
273319
() => {},

0 commit comments

Comments
 (0)