UBER-1117 Add ToDo to wiki documents (#3948)

Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2023-11-07 23:23:07 +07:00 committed by GitHub
parent c9aceeaa8f
commit 1db7ec4d79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 423 additions and 22 deletions

View File

@ -82,8 +82,8 @@
"@tiptap/extension-code": "^2.1.12",
"@tiptap/extension-bubble-menu": "^2.1.12",
"@tiptap/extension-underline": "^2.1.12",
"@hocuspocus/provider": "^2.5.0",
"@tiptap/extension-list-keymap": "^2.1.12",
"@hocuspocus/provider": "^2.5.0",
"slugify": "^1.6.6"
}
}

View File

@ -97,9 +97,9 @@ export const Completion = Node.create<CompletionOptions>({
addAttributes () {
return {
id: getDataAttribute('id', null),
label: getDataAttribute('label', null),
objectclass: getDataAttribute('objectclass', null)
id: getDataAttribute('id'),
label: getDataAttribute('label'),
objectclass: getDataAttribute('objectclass')
}
},

View File

@ -0,0 +1,35 @@
import { TaskItem } from '@tiptap/extension-task-item'
import { TaskList } from '@tiptap/extension-task-list'
import { getDataAttribute } from '../../utils'
export const TodoItemExtension = TaskItem.extend({
name: 'todoItem',
addOptions () {
return {
nested: false,
HTMLAttributes: {},
taskListTypeName: 'todoList'
}
},
addAttributes () {
return {
...this.parent?.(),
todoid: getDataAttribute('todoid', { default: null, keepOnSplit: false }),
userid: getDataAttribute('userid', { default: null, keepOnSplit: false })
}
}
})
export const TodoListExtension = TaskList.extend({
name: 'todoList',
addOptions () {
return {
itemTypeName: 'todoItem',
HTMLAttributes: {}
}
}
})

View File

@ -19,8 +19,8 @@ import Link from '@tiptap/extension-link'
import Typography from '@tiptap/extension-typography'
import { CompletionOptions } from '../Completion'
import MentionList from './MentionList.svelte'
import { SvelteRenderer } from './SvelteRenderer'
import { NodeUuidExtension } from './extension/nodeUuid'
import { SvelteRenderer } from './node-view'
export const tableExtensions = [
Table.configure({
@ -164,9 +164,12 @@ export const completionConfig: Partial<CompletionOptions> = {
return {
onStart: (props: any) => {
component = new SvelteRenderer(MentionList, {
...props,
close: () => {
component.destroy()
element: document.body,
props: {
...props,
close: () => {
component.destroy()
}
}
})
},

View File

@ -0,0 +1,33 @@
<!--
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
-->
<script lang="ts">
import { getNodeViewContext } from './context'
export let as = 'div'
const { onContentElement } = getNodeViewContext()
let element: HTMLElement
$: if (element) {
element.style.whiteSpace = 'pre-wrap'
onContentElement(element)
}
</script>
<svelte:element this={as} bind:this={element} {...$$restProps}>
<slot />
</svelte:element>

View File

@ -0,0 +1,42 @@
<!--
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
-->
<script lang="ts">
import { onMount, tick } from 'svelte'
import { getNodeViewContext } from './context'
export let as = 'div'
const { onDragStart } = getNodeViewContext()
let element: HTMLElement
onMount(async () => {
await tick()
element.style.whiteSpace = 'normal'
})
</script>
<svelte:element
this={as}
bind:this={element}
data-node-view-wrapper=""
on:dragstart={onDragStart}
role="none"
{...$$restProps}
>
<slot />
</svelte:element>

View File

@ -0,0 +1,33 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { getContext } from 'svelte'
const key = 'tiptap-node-view-context'
export interface TiptapNodeViewContext {
onDragStart: (event: DragEvent) => void
onContentElement: (element: HTMLElement) => void
}
export function getNodeViewContext (): TiptapNodeViewContext {
return getContext(key)
}
export function createNodeViewContext (value: TiptapNodeViewContext): Map<any, any> {
const context = new Map()
context.set(key, value)
return context
}

View File

@ -0,0 +1,24 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export { default as NodeViewContent } from './NodeViewContent.svelte'
export { default as NodeViewWrapper } from './NodeViewWrapper.svelte'
export {
default as SvelteNodeViewRenderer,
SvelteNodeViewComponent,
SvelteNodeViewProps as NodeViewProps,
SvelteNodeViewRendererOptions
} from './svelte-node-view-renderer'
export { SvelteRenderer, SvelteRendererComponent, SvelteRendererOptions } from './svelte-renderer'

View File

@ -0,0 +1,155 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
DecorationWithType,
Editor,
NodeView,
NodeViewProps,
NodeViewRenderer,
NodeViewRendererOptions
} from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { ComponentType, SvelteComponent } from 'svelte'
import { createNodeViewContext } from './context'
import { SvelteRenderer } from './svelte-renderer'
export interface SvelteNodeViewRendererOptions extends NodeViewRendererOptions {
update?: (node: ProseMirrorNode, decorations: DecorationWithType[]) => boolean
contentAs?: string
contentDOMElementAs?: string
componentProps?: Record<string, any>
}
export type SvelteNodeViewProps = NodeViewProps & {
[key: string]: any
}
export type SvelteNodeViewComponent = typeof SvelteComponent | ComponentType
/**
* Svelte NodeView renderer, inspired by React and Vue implementation by Tiptap
* https://tiptap.dev/guide/node-views/react/
*/
class SvelteNodeView extends NodeView<SvelteNodeViewComponent, Editor, SvelteNodeViewRendererOptions> {
renderer!: SvelteRenderer
contentDOMElement!: HTMLElement | null
override mount (): void {
const props: SvelteNodeViewProps = {
editor: this.editor,
node: this.node,
decorations: this.decorations,
selected: false,
extension: this.extension,
getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
deleteNode: () => this.deleteNode(),
...(this.options.componentProps ?? {})
}
if (this.node.isLeaf) {
this.contentDOMElement = null
} else if (this.options.contentDOMElementAs !== undefined) {
this.contentDOMElement = document.createElement(this.options.contentDOMElementAs)
} else {
this.contentDOMElement = document.createElement(this.node.isInline ? 'span' : 'div')
}
if (this.contentDOMElement !== null) {
// For some reason the whiteSpace prop is not inherited properly in Chrome and Safari
// With this fix it seems to work fine
// See: https://github.com/ueberdosis/tiptap/issues/1197
this.contentDOMElement.style.whiteSpace = 'inherit'
}
const contentAs = this.options.contentAs ?? (this.node.isInline ? 'span' : 'div')
const target = document.createElement(contentAs)
target.classList.add(`node-${this.node.type.name}`)
const context = createNodeViewContext({
onDragStart: this.onDragStart.bind(this),
onContentElement: (element) => {
if (this.contentDOMElement !== null && !element.contains(this.contentDOMElement)) {
element.appendChild(this.contentDOMElement)
}
}
})
this.renderer = new SvelteRenderer(this.component, { element: target, props, context })
}
override get dom (): HTMLElement {
if (this.renderer.element.firstElementChild?.hasAttribute('data-node-view-wrapper') === false) {
throw Error('Please use the NodeViewWrapper component for your node view.')
}
return this.renderer.element
}
override get contentDOM (): HTMLElement | null {
if (this.node.isLeaf) {
return null
}
return this.contentDOMElement
}
update (node: ProseMirrorNode, decorations: DecorationWithType[]): boolean {
if (typeof this.options.update === 'function') {
return this.options.update(node, decorations)
}
if (node.type !== this.node.type) {
return false
}
if (node === this.node && this.decorations === decorations) {
return true
}
this.node = node
this.decorations = decorations
this.renderer.updateProps({ node, decorations })
return true
}
selectNode (): void {
this.renderer.updateProps({ selected: true })
}
deselectNode (): void {
this.renderer.updateProps({ selected: false })
}
destroy (): void {
this.renderer.destroy()
this.contentDOMElement = null
}
}
const SvelteNodeViewRenderer = (
component: SvelteNodeViewComponent,
options: Partial<SvelteNodeViewRendererOptions>
): NodeViewRenderer => {
return (props) => new SvelteNodeView(component, props, options)
}
export default SvelteNodeViewRenderer

View File

@ -1,6 +1,6 @@
//
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
// Copyright © 2021, 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@ -14,14 +14,27 @@
// limitations under the License.
//
import { ComponentType, SvelteComponent } from 'svelte'
import type { ComponentType, SvelteComponent } from 'svelte'
export type SvelteRendererComponent = typeof SvelteComponent | ComponentType
export interface SvelteRendererOptions {
element: HTMLElement
props?: any
context?: any
}
export class SvelteRenderer {
private readonly component: SvelteComponent
element: HTMLElement
constructor (comp: typeof SvelteComponent | ComponentType, props: any) {
const options = { target: document.body, props }
this.component = new (comp as any)(options)
constructor (component: SvelteRendererComponent, { element, props, context }: SvelteRendererOptions) {
this.element = element
this.element.classList.add('svelte-renderer')
const options = { target: element, props, context }
const Component = component
this.component = new Component(options)
}
updateProps (props: Record<string, any>): void {

View File

@ -1,6 +1,6 @@
//
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
// Copyright © 2021, 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@ -30,6 +30,7 @@ export { default as TextEditor } from './components/TextEditor.svelte'
export { default as TextEditorStyleToolbar } from './components/TextEditorStyleToolbar.svelte'
export { default as AttachIcon } from './components/icons/Attach.svelte'
export { default as TableOfContents } from './components/toc/TableOfContents.svelte'
export * from './components/node-view'
export { default } from './plugin'
export * from './types'
export * from './utils'
@ -58,5 +59,6 @@ export {
type InlineStyleToolbarStorage
} from './components/extension/inlineStyleToolbar'
export { ImageExtension, type ImageOptions } from './components/extension/imageExt'
export { TodoItemExtension, TodoListExtension } from './components/extension/todo'
export { textEditorId }

View File

@ -78,11 +78,14 @@ export function copyDocumentContent (
provider.copyContent(documentId, snapshotId)
}
export function getDataAttribute (name: string, def?: unknown | null): Partial<Attribute> {
export function getDataAttribute (
name: string,
options?: Omit<Attribute, 'parseHTML' | 'renderHTML'>
): Partial<Attribute> {
const dataName = `data-${name}`
return {
default: def,
default: null,
parseHTML: (element) => element.getAttribute(dataName),
renderHTML: (attributes) => {
// eslint-disable-next-line
@ -93,6 +96,7 @@ export function getDataAttribute (name: string, def?: unknown | null): Partial<A
return {
[dataName]: attributes[name]
}
}
},
...(options !== undefined ? options : {})
}
}

View File

@ -25,7 +25,7 @@ import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import Typography from '@tiptap/extension-typography'
import StarterKit from '@tiptap/starter-kit'
import { ImageNode, ReferenceNode } from './nodes'
import { ImageNode, ReferenceNode, TodoItemNode, TodoListNode } from './nodes'
/**
* @public
@ -97,4 +97,10 @@ export const defaultExtensions: AnyExtension[] = [
/**
* @public
*/
export const serverExtensions: AnyExtension[] = [...defaultExtensions, ImageNode, ReferenceNode]
export const serverExtensions: AnyExtension[] = [
...defaultExtensions,
ImageNode,
ReferenceNode,
TodoItemNode,
TodoListNode
]

View File

@ -15,4 +15,5 @@
export * from './image'
export * from './reference'
export * from './todo'
export { getDataAttribute } from './utils'

View File

@ -0,0 +1,35 @@
import { TaskItem } from '@tiptap/extension-task-item'
import { TaskList } from '@tiptap/extension-task-list'
import { getDataAttribute } from './utils'
export const TodoItemNode = TaskItem.extend({
name: 'todoItem',
addOptions () {
return {
nested: false,
HTMLAttributes: {},
taskListTypeName: 'todoList'
}
},
addAttributes () {
return {
...this.parent?.(),
todoid: getDataAttribute('todoid', { default: null, keepOnSplit: false }),
userid: getDataAttribute('userid', { default: null, keepOnSplit: false })
}
}
})
export const TodoListNode = TaskList.extend({
name: 'todoList',
addOptions () {
return {
itemTypeName: 'todoItem',
HTMLAttributes: {}
}
}
})

View File

@ -18,11 +18,14 @@ import { Attribute } from '@tiptap/core'
/**
* @public
*/
export function getDataAttribute (name: string, def?: unknown | null): Partial<Attribute> {
export function getDataAttribute (
name: string,
options?: Omit<Attribute, 'parseHTML' | 'renderHTML'>
): Partial<Attribute> {
const dataName = `data-${name}`
return {
default: def,
default: null,
parseHTML: (element) => element.getAttribute(dataName),
renderHTML: (attributes) => {
// eslint-disable-next-line
@ -33,6 +36,7 @@ export function getDataAttribute (name: string, def?: unknown | null): Partial<A
return {
[dataName]: attributes[name]
}
}
},
...(options !== undefined ? options : {})
}
}

View File

@ -76,6 +76,17 @@
margin: 0;
}
ul[data-type="todoList"] {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0;
padding: 0;
}
}
ol ol { list-style: lower-alpha; }
ol ol ol { list-style: lower-roman; }
ol ol ol ol { list-style: decimal; }