Compare commits

...

1 commit

Author SHA1 Message Date
sevichecc
cd787f3953
Add BlockNote,ref https://github.com/TypeCellOS/BlockNote/pull/130/files 2023-03-23 18:28:36 +08:00
17 changed files with 2340 additions and 160 deletions

View file

@ -1,3 +0,0 @@
{
"extends": "@antfu"
}

15
.eslintrc.cjs Normal file
View file

@ -0,0 +1,15 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'@antfu',
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
],
parserOptions: {
ecmaVersion: 'latest',
},
}

View file

@ -6,13 +6,23 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}, },
"dependencies": { "dependencies": {
"@blocknote/core": "^0.4.5",
"@tiptap/vue-3": "2.0.0-beta.220",
"masto": "^5.10.0", "masto": "^5.10.0",
"vue": "^3.2.45" "vue": "^3.2.47"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
"@types/node": "^18.14.2",
"@vue/eslint-config-typescript": "^11.0.2",
"@vue/tsconfig": "^0.1.3",
"eslint-plugin-vue": "^9.9.0",
"npm-run-all": "^4.1.5",
"@antfu/eslint-config": "^0.35.2", "@antfu/eslint-config": "^0.35.2",
"@vitejs/plugin-vue": "^4.0.0", "@vitejs/plugin-vue": "^4.0.0",
"eslint": "^8.34.0", "eslint": "^8.34.0",

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,24 @@
<script lang="ts" setup>
import { ref } from 'vue'
import blocks from './blocks.json'
import BlockNoteView from './components/BlockNoteView.vue'
// TODO fix any
const content = ref<any[]>(blocks)
// const content: Block[] = ref(blocks) as Block[]
</script>
<template> <template>
<h2>App</h2> <BlockNoteView v-model="content">
<template #BlockSideMenu-AddBlock="{ staticParams }">
<button @click="staticParams.addBlock()">
+
</button>
</template>
<template #SlashMenuItem-Heading="{ menu, selected, onClick }">
<button :style="selected && 'font-weight: bold;'" @click="onClick()">
Slot {{ menu.name }}
</button>
</template>
</BlockNoteView>
</template> </template>

128
src/blocks.json Normal file
View file

@ -0,0 +1,128 @@
[
{
"id": "53fa8b49-af0b-457e-9e1d-dd254215720f",
"type": "paragraph",
"props": {
"textColor": "default",
"backgroundColor": "default",
"textAlignment": "left"
},
"content": [
{
"type": "text",
"text": "Curabitur nisi.",
"styles": {}
}
],
"children": []
},
{
"id": "aedd1f50-d699-4de1-9dd3-7841bc5f1fda",
"type": "paragraph",
"props": {
"textColor": "default",
"backgroundColor": "default",
"textAlignment": "left"
},
"content": [
{
"type": "text",
"text": "Vesatibulum ",
"styles": {}
},
{
"type": "text",
"text": "rutrum",
"styles": {
"bold": true
}
},
{
"type": "text",
"text": ", mi nec ",
"styles": {}
},
{
"type": "text",
"text": "elementum",
"styles": {
"italic": true
}
},
{
"type": "text",
"text": " vehicula, eros quam gravida nisl, id fringilla neque ante vel mi.",
"styles": {}
}
],
"children": []
},
{
"id": "53f09254-e419-44ea-a497-7b1087763a27",
"type": "paragraph",
"props": {
"textColor": "default",
"backgroundColor": "default",
"textAlignment": "left"
},
"content": [
{
"type": "text",
"text": "Vestibulum ullamcorper mauris at ligula.",
"styles": {}
}
],
"children": []
},
{
"id": "34df38b2-9c4b-4a0c-89fa-3bfa694d72ed",
"type": "paragraph",
"props": {
"textColor": "default",
"backgroundColor": "default",
"textAlignment": "left"
},
"content": [
{
"type": "text",
"text": "Maecenas vestibulum mollis diam.",
"styles": {}
}
],
"children": []
},
{
"id": "485903b5-2cd7-4e44-b0ac-978ff2f4f5bd",
"type": "paragraph",
"props": {
"textColor": "default",
"backgroundColor": "default",
"textAlignment": "left"
},
"content": [
{
"type": "text",
"text": "Donec posuere vulputate arcu.",
"styles": {}
}
],
"children": []
},
{
"id": "98a64490-e4d2-4928-9665-1efa7f32b3db",
"type": "paragraph",
"props": {
"textColor": "default",
"backgroundColor": "default",
"textAlignment": "left"
},
"content": [
{
"type": "text",
"text": "auie",
"styles": {}
}
],
"children": []
}
]

View file

@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { ComponentInternalInstance } from 'vue'
import { getCurrentInstance, onMounted, ref } from 'vue'
import { BlockNoteEditor, defaultSlashMenuItems } from '@blocknote/core'
import type { Block, BlockNoteEditorOptions } from '@blocknote/core'
import { EditorContent } from '@tiptap/vue-3'
import { slashMenuFactory } from './SlashMenu/slashMenuFactory'
import { blockSideMenuFactory } from './BlockSideMenu/blockSideMenuFactory'
const props = defineProps<{
modelValue: Block[]
options?: BlockNoteEditorOptions
}>()
const emit = defineEmits<{
(event: 'update:modelValue', payload: Block[]): void
}>()
const component: ComponentInternalInstance = getCurrentInstance()!
const editor = ref()
onMounted(async () => {
// Convert md to html
const Editor = new BlockNoteEditor({})
const content = await Editor.blocksToHTML(props.modelValue)
const editor = new BlockNoteEditor({
parentElement: document.getElementById('app')!,
slashCommands: defaultSlashMenuItems,
uiFactories: {
// Create an example formatting toolbar which just consists of a bold toggle
// formattingToolbarFactory,
// // Create an example menu for hyperlinks
// hyperlinkToolbarFactory,
// Create an example menu for the /-menu
slashMenuFactory: slashMenuFactory(component),
// // Create an example menu for when a block is hovered
blockSideMenuFactory: blockSideMenuFactory(component),
},
onEditorContentChange() {
emit('update:modelValue', editor.topLevelBlocks)
},
editorDOMAttributes: {
class: 'editor',
},
_tiptapOptions: {
content,
},
})
// console.log(editor)
})
</script>
<template>
<div>
<EditorContent :editor="editor?._tiptapEditor" />
</div>
</template>

View file

@ -0,0 +1,101 @@
<script lang="ts" setup>
const props = defineProps<{
staticParams?: any
}>()
function addBlock() {
props.staticParams.addBlock()
}
function onDragStart(event: DragEvent) {
props.staticParams.blockDragStart(event)
}
function onDragEnd(event: DragEvent) {
props.staticParams.blockDragEnd(event)
}
</script>
<template>
<div>
<slot :static-params="staticParams">
<slot name="BlockSideMenu-AddBlock" :static-params="staticParams">
<button class="block-side-menu__button" @click="addBlock">
<svg
stroke="currentColor" fill="currentColor" stroke-width="0" t="1551322312294" viewBox="0 0 1024 1024"
version="1.1" height="24" width="24" xmlns="http://www.w3.org/2000/svg"
>
<defs />
<path d="M474 152m8 0l60 0q8 0 8 8l0 704q0 8-8 8l-60 0q-8 0-8-8l0-704q0-8 8-8Z" />
<path d="M168 474m8 0l672 0q8 0 8 8l0 60q0 8-8 8l-672 0q-8 0-8-8l0-60q0-8 8-8Z" />
</svg>
</button>
</slot>
<slot name="BlockSideMenu-Drag" :static-params="staticParams">
<button class="block-side-menu__button" draggable="true" @dragstart="onDragStart" @dragend="onDragEnd">
<svg
stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="24" width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path fill="none" d="M0 0h24v24H0V0z" />
<path
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</button>
</slot>
</slot>
</div>
</template>
<style>
.block-side-menu {
position: absolute;
display: none;
margin-top: -4px;
}
.block-side-menu>div {
display: flex !important;
flex-direction: row;
}
.ProseMirror {
outline: none;
}
</style>
<style lang="postcss">
.block-side-menu__button {
-webkit-tap-highlight-color: transparent;
font-family: Inter;
cursor: pointer;
appearance: none;
font-size: 16px;
text-align: left;
text-decoration: none;
box-sizing: border-box;
border: 1px solid transparent;
background-color: transparent;
color: rgb(194, 199, 208);
position: relative;
height: 24px;
min-height: 24px;
width: 24px;
min-width: 24px;
border-radius: 4px;
padding: 0px;
line-height: 1;
display: flex;
-webkit-box-align: center;
align-items: center;
-webkit-box-pack: center;
justify-content: center;
}
.block-side-menu__button:hover {
background-color: rgb(246, 246, 248);
}
.block-side-menu__button:focus {
outline-offset: 2px;
outline: rgb(137, 147, 164) solid 2px;
}
button:focus:not(:focus-visible) {
outline: none !important;
}
</style>

View file

@ -0,0 +1,48 @@
import type { BlockSideMenuFactory, BlockSideMenuStaticParams } from '@blocknote/core'
import type { ComponentInternalInstance } from 'vue'
import { mount } from '../../mount'
import BlockSideMenu from './BlockSideMenu.vue'
/**
* This menu is drawn next to a block, when it's hovered over
* It renders a drag handle and + button to create a new block
*/
export function blockSideMenuFactory(component: ComponentInternalInstance) {
const blockSideMenuFactory: BlockSideMenuFactory = (staticParams: BlockSideMenuStaticParams) => {
// Mount component
// https://github.com/pearofducks/mount-vue-component/blob/master/index.js
const { el } = mount(BlockSideMenu, {
app: component.appContext.app,
children: component.slots, // Pass all slots or filter for SideMenu ?
props: {
staticParams,
},
})
el.classList.add('block-side-menu')
document.body.appendChild(el)
// Mount component as a new instance
// const container = document.createElement("div")
// const instance = createApp(BlockSideMenu, {
// staticParams
// }).mount(container)
// document.body.appendChild(el)
return {
element: el,
render: (params, isHidden) => {
if (isHidden)
el.style.display = 'block'
el.style.top = `${params.referenceRect.y}px`
el.style.left = `${params.referenceRect.x - el.offsetWidth}px`
},
hide: () => {
el.style.display = 'none'
},
}
}
return blockSideMenuFactory
}

53
src/components/Editor.vue Normal file
View file

@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { ComponentInternalInstance } from "vue"
import { onMounted, ref, useSlots, getCurrentInstance } from "vue"
import { BlockNoteEditor, defaultSlashMenuItems } from "@blocknote/core"
import type { BlockNoteEditorOptions, Block } from "@blocknote/core"
import { EditorContent } from "@tiptap/vue-3"
import { slashMenuFactory } from "@/SlashMenu/slashMenuFactory"
import { blockSideMenuFactory } from "@/BlockSideMenu/blockSideMenuFactory"
const props = defineProps<{
modelValue: Block[]
options?: BlockNoteEditorOptions
}>()
const emit = defineEmits<{
(event: 'update:modelValue', payload: Block[]): void
}>()
const component: ComponentInternalInstance = getCurrentInstance()!
const editor = ref()
onMounted(async () => {
// Convert md to html
const Editor = new BlockNoteEditor({})
const content = await Editor.blocksToHTML(props.modelValue)
const editor = new BlockNoteEditor({
parentElement: document.getElementById("app")!,
slashCommands: defaultSlashMenuItems,
uiFactories: {
// Create an example formatting toolbar which just consists of a bold toggle
// formattingToolbarFactory,
// // Create an example menu for hyperlinks
// hyperlinkToolbarFactory,
// Create an example menu for the /-menu
slashMenuFactory: slashMenuFactory(component),
// // Create an example menu for when a block is hovered
blockSideMenuFactory: blockSideMenuFactory(component),
},
onEditorContentChange() {
emit('update:modelValue', editor.topLevelBlocks)
},
editorDOMAttributes: {
class: "editor",
},
_tiptapOptions: {
content
}
})
// console.log(editor)
})
</script>
<template>
<div>
<editor-content :editor="editor?._tiptapEditor" />
</div>
</template>

0
src/components/Login.vue Normal file
View file

View file

@ -0,0 +1,35 @@
<script lang="ts" setup>
import type { BaseSlashMenuItem } from '@blocknote/core'
import type { SlashMenuProps } from './slashMenuFactory'
import SlashMenuItem from './SlashMenuItem.vue'
const props = defineProps<{
reactiveParams: SlashMenuProps
}>()
function onClick(menu: BaseSlashMenuItem) {
props.reactiveParams.itemCallback(menu)
}
function isSelected(i: number) {
return props.reactiveParams.keyboardHoveredItemIndex === i
}
</script>
<template>
<slot name="SlashMenu">
<template v-for="(menu, i) in reactiveParams.items" :key="menu.name">
<slot :name="`SlashMenuItem-${menu.name}`" :menu="menu" :selected="isSelected(i)" :on-click="() => onClick(menu)">
<SlashMenuItem :menu="menu" :selected="isSelected(i)" :on-click="() => onClick(menu)" />
</slot>
</template>
</slot>
</template>
<style style="postcss">
.slash-menu {
position: absolute;
background: white;
box-shadow: rgb(223, 225, 230) 0px 4px 8px, rgb(223, 225, 230) 0px 0px 1px;
border: 1px solid rgb(236, 237, 240);
border-radius: 6px;
padding: 4px;
}
</style>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { BaseSlashMenuItem } from '@blocknote/core'
const props = defineProps<{
menu: BaseSlashMenuItem
selected: boolean
onClick: Function
}>()
</script>
<template>
<div>
<button :class="selected && 'selected'" @click="onClick()">
{{ menu.name }}
</button>
</div>
</template>
<style style="postcss" scoped>
.selected {
font-weight: bold;
}
</style>

View file

@ -0,0 +1,51 @@
import type { BaseSlashMenuItem, SuggestionsMenuFactory } from '@blocknote/core'
import type { ComponentInternalInstance } from 'vue'
import { reactive } from 'vue'
import { mount } from '../../mount'
import SlashMenu from './SlashMenu.vue'
export interface SlashMenuProps {
items: BaseSlashMenuItem[]
keyboardHoveredItemIndex: number
itemCallback: (item: BaseSlashMenuItem) => void
}
/**
* This menu is drawn next to a block, when it's hovered over
* It renders a drag handle and + button to create a new block
*/
export function slashMenuFactory(component: ComponentInternalInstance) {
const slashMenuFactory: SuggestionsMenuFactory<BaseSlashMenuItem> = (staticParams: SlashMenuProps) => {
// Mount component
const reactiveParams = reactive<SlashMenuProps>(staticParams)
const { el } = mount(SlashMenu, {
app: component.appContext.app,
children: component.slots, // Pass all slots or filter for slashMenu ?
props: { reactiveParams },
})
el.classList.add('slash-menu')
document.body.appendChild(el)
return {
element: el,
render: (params, isActive) => {
Object.assign(reactiveParams, params)
if (isActive)
el.style.display = 'block'
el.style.top = `${params.referenceRect.y}px`
el.style.left = `${params.referenceRect.x - el.offsetWidth}px`
},
hide: () => {
el.style.display = 'none'
},
}
}
return slashMenuFactory
}

52
src/mount.ts Normal file
View file

@ -0,0 +1,52 @@
import type { App, Component, VNode } from 'vue'
import { cloneVNode, createVNode, render } from 'vue'
/**
* Inspiration from https://github.com/pearofducks/mount-vue-component/blob/master/index.js
* And official `mount` Vue3 https://github.com/vuejs/core/blob/650f5c26f464505d9e865bdb0eafb24350859528/packages/runtime-core/src/apiCreateApp.ts#L294
*/
interface Mount {
props: any
children?: unknown
element?: HTMLElement
app: App
}
const __DEV__ = process.env.NODE_ENV === 'development'
export function mount(component: Component, { props, children, element, app }: Mount): {
vnode: VNode, destroy: () => void, el: HTMLElement
} {
const el = element || document.createElement('div')
let vnode: VNode | null = createVNode(component, props, children)
if (app && app._context)
vnode.appContext = app._context
// HMR root reload
if (__DEV__) {
app._context.reload = () => {
render(cloneVNode(vnode!), el) // , isSVG)
}
}
if (el)
render(vnode, el)
else if (typeof document !== 'undefined')
render(vnode, el)
// if (isHydrate && hydrate) {
// hydrate(vnode as VNode<Node, Element>, el as any)
// } else {
// render(vnode, el, isSVG)
// }
const destroy = () => {
if (el)
render(null, el)
vnode = null
}
return { vnode, destroy, el: el as HTMLElement }
}

View file

@ -78,3 +78,14 @@ button:focus-visible {
background-color: #f9f9f9; background-color: #f9f9f9;
} }
} }
html,
body,
#root {
height: 100%;
}
.editor {
padding: 0 calc((100% - 731px) / 2);
height: 100%;
}

View file

@ -1,7 +1,27 @@
import { URL, fileURLToPath } from 'node:url'
import * as path from 'path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig((conf) => {
plugins: [vue()], const config = {
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
}
// Comment out the lines below to load a built version of blocknote
// or, keep as is to load live from sources with live reload working
if (conf.command === 'build') {
Object.assign(config.resolve.alias, {
'@blocknote/core': path.resolve(__dirname, '../../packages/core/src/'),
})
}
return config
}) })