mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-19 12:50:20 +00:00
chore(ui): add focus, block and drag n drop extension in block editor (#13675)
* chore(ui): add focus, block and drag n drop extension in block editor * chore: add title to handles * add spacing for last node * only apply spacing to direct child
This commit is contained in:
parent
0fee1ccb3a
commit
32ac45f11f
@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M2.25 0A2.25 2.25 0 000 2.25v7.5A2.25 2.25 0 002.25 12h.25a.75.75 0 000-1.5h-.25a.75.75 0 01-.75-.75v-7.5a.75.75 0 01.75-.75h7.5a.75.75 0 01.75.75v.25a.75.75 0 001.5 0v-.25A2.25 2.25 0 009.75 0h-7.5z"/>
|
||||||
|
<path fill-rule="evenodd" d="M6.25 4A2.25 2.25 0 004 6.25v7.5A2.25 2.25 0 006.25 16h7.5A2.25 2.25 0 0016 13.75v-7.5A2.25 2.25 0 0013.75 4h-7.5zM5.5 6.25a.75.75 0 01.75-.75h7.5a.75.75 0 01.75.75v7.5a.75.75 0 01-.75.75h-7.5a.75.75 0 01-.75-.75v-7.5z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 570 B |
@ -49,6 +49,11 @@ const BlockEditor = forwardRef<BlockEditorRef, BlockEditorProps>(
|
|||||||
|
|
||||||
onChange?.(backendFormat);
|
onChange?.(backendFormat);
|
||||||
},
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'om-block-editor',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
|
@ -0,0 +1,184 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Collate.
|
||||||
|
* Licensed under the Apache 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
|
||||||
|
* http://www.apache.org/licenses/LICENSE-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 React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import tippy, { Instance } from 'tippy.js';
|
||||||
|
|
||||||
|
import { Editor } from '@tiptap/react';
|
||||||
|
|
||||||
|
import { NodeSelection } from '@tiptap/pm/state';
|
||||||
|
import { isUndefined } from 'lodash';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg';
|
||||||
|
import { ReactComponent as DuplicateIcon } from '../../../assets/svg/ic-duplicate.svg';
|
||||||
|
import {
|
||||||
|
nodeDOMAtCoords,
|
||||||
|
nodePosAtDOM,
|
||||||
|
} from '../Extensions/BlockAndDragDrop/helpers';
|
||||||
|
import './block-menu.less';
|
||||||
|
|
||||||
|
interface BlockMenuProps {
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BlockMenu = (props: BlockMenuProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { editor } = props;
|
||||||
|
const { view } = editor;
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const popup = useRef<Instance | null>(null);
|
||||||
|
|
||||||
|
const handleClickBlockHandle = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
const { view: editorView } = editor;
|
||||||
|
|
||||||
|
const node = nodeDOMAtCoords({
|
||||||
|
x: event.clientX + 24 * 4 + 24,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(node instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodePos = nodePosAtDOM(node, editorView);
|
||||||
|
if (isUndefined(nodePos)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeSelection = NodeSelection.create(editorView.state.doc, nodePos);
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.insertContentAt(
|
||||||
|
nodeSelection.to,
|
||||||
|
{ type: 'paragraph' },
|
||||||
|
{
|
||||||
|
updateSelection: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.focus(nodeSelection.to)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClickDragHandle = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
if (target.matches('[data-block-handle]')) {
|
||||||
|
handleClickBlockHandle(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.matches('[data-drag-handle]')) {
|
||||||
|
popup.current?.hide();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
popup.current?.setProps({
|
||||||
|
getReferenceClientRect: () => target.getBoundingClientRect(),
|
||||||
|
});
|
||||||
|
|
||||||
|
popup.current?.show();
|
||||||
|
},
|
||||||
|
[view]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = () => {
|
||||||
|
popup.current?.hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
const { view } = editor;
|
||||||
|
const { state } = view;
|
||||||
|
const { selection } = state;
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.insertContentAt(
|
||||||
|
selection.to,
|
||||||
|
selection.content().content.firstChild?.toJSON(),
|
||||||
|
{
|
||||||
|
updateSelection: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.focus(selection.to)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
popup.current?.hide();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
popup.current?.hide();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuRef.current) {
|
||||||
|
menuRef.current.remove();
|
||||||
|
menuRef.current.style.visibility = 'visible';
|
||||||
|
|
||||||
|
popup.current = tippy(view.dom, {
|
||||||
|
getReferenceClientRect: null,
|
||||||
|
content: menuRef.current,
|
||||||
|
appendTo: 'parent',
|
||||||
|
trigger: 'manual',
|
||||||
|
interactive: true,
|
||||||
|
arrow: false,
|
||||||
|
placement: 'top',
|
||||||
|
hideOnClick: true,
|
||||||
|
onShown: () => {
|
||||||
|
menuRef.current?.focus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
popup.current?.destroy();
|
||||||
|
popup.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('click', handleClickDragHandle);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickDragHandle);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleClickDragHandle, handleKeyDown]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="block-menu" ref={menuRef}>
|
||||||
|
<button className="action" onClick={handleDelete}>
|
||||||
|
<div className="action-icon-container">
|
||||||
|
<DeleteIcon width={16} />
|
||||||
|
</div>
|
||||||
|
<div className="action-name">{t('label.delete')}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="action" onClick={handleDuplicate}>
|
||||||
|
<div className="action-icon-container">
|
||||||
|
<DuplicateIcon width={16} />
|
||||||
|
</div>
|
||||||
|
<div className="action-name">{t('label.duplicate')}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlockMenu;
|
@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Collate.
|
||||||
|
* Licensed under the Apache 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
|
||||||
|
* http://www.apache.org/licenses/LICENSE-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 (reference) url('../../../styles/variables.less');
|
||||||
|
|
||||||
|
.tippy-box {
|
||||||
|
max-width: 400px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-menu {
|
||||||
|
max-height: 300px;
|
||||||
|
padding: 12px 0px;
|
||||||
|
background-color: @white;
|
||||||
|
border: @global-border;
|
||||||
|
border-radius: @border-radius-base;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
.action {
|
||||||
|
background: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: @grey-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-name {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -15,6 +15,7 @@ import { isEmpty, isNil } from 'lodash';
|
|||||||
import React, { forwardRef, useImperativeHandle, useState } from 'react';
|
import React, { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
import tippy, { Instance, Props } from 'tippy.js';
|
import tippy, { Instance, Props } from 'tippy.js';
|
||||||
import { EditorSlotsRef } from './BlockEditor.interface';
|
import { EditorSlotsRef } from './BlockEditor.interface';
|
||||||
|
import BlockMenu from './BlockMenu/BlockMenu';
|
||||||
import BubbleMenu from './BubbleMenu/BubbleMenu';
|
import BubbleMenu from './BubbleMenu/BubbleMenu';
|
||||||
import LinkModal, { LinkData } from './LinkModal/LinkModal';
|
import LinkModal, { LinkData } from './LinkModal/LinkModal';
|
||||||
import LinkPopup from './LinkPopup/LinkPopup';
|
import LinkPopup from './LinkPopup/LinkPopup';
|
||||||
@ -164,6 +165,7 @@ const EditorSlots = forwardRef<EditorSlotsRef, EditorSlotsProps>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{menus}
|
{menus}
|
||||||
|
{!isNil(editor) && <BlockMenu editor={editor} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Collate.
|
||||||
|
* Licensed under the Apache 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
|
||||||
|
* http://www.apache.org/licenses/LICENSE-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 { Extension } from '@tiptap/core';
|
||||||
|
import { BlockAndDragHandle } from './BlockAndDragHandle';
|
||||||
|
|
||||||
|
export interface BlockAndDragHandleOptions {
|
||||||
|
/**
|
||||||
|
* The width of the drag handle
|
||||||
|
*/
|
||||||
|
dragHandleWidth: number;
|
||||||
|
/**
|
||||||
|
* The width of the drag handle
|
||||||
|
*/
|
||||||
|
blockHandleWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
interface DragAndDropOptions {}
|
||||||
|
|
||||||
|
const DragAndDrop = Extension.create<DragAndDropOptions>({
|
||||||
|
name: 'dragAndDrop',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
BlockAndDragHandle({
|
||||||
|
dragHandleWidth: 24,
|
||||||
|
blockHandleWidth: 24,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DragAndDrop;
|
@ -10,57 +10,20 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import { Extension } from '@tiptap/core';
|
|
||||||
|
|
||||||
import { NodeSelection, Plugin } from '@tiptap/pm/state';
|
import { NodeSelection, Plugin } from '@tiptap/pm/state';
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { EditorView, __serializeForClipboard } from '@tiptap/pm/view';
|
import { EditorView, __serializeForClipboard } from '@tiptap/pm/view';
|
||||||
|
import { isUndefined } from 'lodash';
|
||||||
|
import { BlockAndDragHandleOptions } from './BlockAndDragDrop';
|
||||||
|
import { absoluteRect, nodeDOMAtCoords, nodePosAtDOM } from './helpers';
|
||||||
|
|
||||||
export interface DragHandleOptions {
|
export const BlockAndDragHandle = (options: BlockAndDragHandleOptions) => {
|
||||||
/**
|
let dragHandleElement: HTMLElement | null = null;
|
||||||
* The width of the drag handle
|
let blockHandleElement: HTMLElement | null = null;
|
||||||
*/
|
|
||||||
dragHandleWidth: number;
|
|
||||||
}
|
|
||||||
const absoluteRect = (node: Element) => {
|
|
||||||
const data = node.getBoundingClientRect();
|
|
||||||
|
|
||||||
return {
|
// Drag Handle handlers
|
||||||
top: data.top,
|
|
||||||
left: data.left,
|
|
||||||
width: data.width,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
|
||||||
return document
|
|
||||||
.elementsFromPoint(coords.x, coords.y)
|
|
||||||
.find(
|
|
||||||
(elem: Element) =>
|
|
||||||
elem.parentElement?.matches?.('.ProseMirror') ||
|
|
||||||
elem.matches(
|
|
||||||
[
|
|
||||||
'li',
|
|
||||||
'p:not(:first-child)',
|
|
||||||
'pre',
|
|
||||||
'blockquote',
|
|
||||||
'h1, h2, h3, h4, h5, h6',
|
|
||||||
].join(', ')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodePosAtDOM = (node: Element, view: EditorView) => {
|
|
||||||
const boundingRect = node.getBoundingClientRect();
|
|
||||||
|
|
||||||
return view.posAtCoords({
|
|
||||||
left: boundingRect.left + 1,
|
|
||||||
top: boundingRect.top + 1,
|
|
||||||
})?.inside;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DragHandle = (options: DragHandleOptions) => {
|
|
||||||
const handleDragStart = (event: DragEvent, view: EditorView) => {
|
const handleDragStart = (event: DragEvent, view: EditorView) => {
|
||||||
view.focus();
|
view.focus();
|
||||||
|
|
||||||
@ -78,7 +41,7 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nodePos = nodePosAtDOM(node, view);
|
const nodePos = nodePosAtDOM(node, view);
|
||||||
if (nodePos == null || nodePos < 0) {
|
if (isUndefined(nodePos)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +62,7 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||||||
view.dragging = { slice, move: event.ctrlKey };
|
view.dragging = { slice, move: event.ctrlKey };
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent, view: EditorView) => {
|
const handleDragClick = (event: MouseEvent, view: EditorView) => {
|
||||||
view.focus();
|
view.focus();
|
||||||
|
|
||||||
view.dom.classList.remove('om-node-dragging');
|
view.dom.classList.remove('om-node-dragging');
|
||||||
@ -114,7 +77,7 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nodePos = nodePosAtDOM(node, view);
|
const nodePos = nodePosAtDOM(node, view);
|
||||||
if (!nodePos) {
|
if (isUndefined(nodePos)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,8 +86,6 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let dragHandleElement: HTMLElement | null = null;
|
|
||||||
|
|
||||||
const hideDragHandle = () => {
|
const hideDragHandle = () => {
|
||||||
if (dragHandleElement) {
|
if (dragHandleElement) {
|
||||||
dragHandleElement.classList.add('hidden');
|
dragHandleElement.classList.add('hidden');
|
||||||
@ -137,27 +98,128 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMouseMoveForDragHandle = (event: MouseEvent) => {
|
||||||
|
const node = nodeDOMAtCoords({
|
||||||
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(node instanceof Element) || node.matches('ul, ol')) {
|
||||||
|
hideDragHandle();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compStyle = window.getComputedStyle(node);
|
||||||
|
const lineHeight = parseInt(compStyle.lineHeight, 10);
|
||||||
|
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||||
|
|
||||||
|
const rect = absoluteRect(node);
|
||||||
|
|
||||||
|
rect.top += (lineHeight - 24) / 2;
|
||||||
|
rect.top += paddingTop;
|
||||||
|
// Li markers
|
||||||
|
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
||||||
|
rect.left -= options.dragHandleWidth;
|
||||||
|
}
|
||||||
|
rect.width = options.dragHandleWidth;
|
||||||
|
|
||||||
|
if (!dragHandleElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||||
|
dragHandleElement.style.top = `${rect.top}px`;
|
||||||
|
showDragHandle();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Block Handle handlers
|
||||||
|
|
||||||
|
const hideBlockHandle = () => {
|
||||||
|
if (blockHandleElement) {
|
||||||
|
blockHandleElement.classList.add('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showBlockHandle = () => {
|
||||||
|
if (blockHandleElement) {
|
||||||
|
blockHandleElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMoveForBlockHandle = (event: MouseEvent) => {
|
||||||
|
const node = nodeDOMAtCoords({
|
||||||
|
x: event.clientX + options.dragHandleWidth * 4 + options.blockHandleWidth,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(node instanceof Element) || node.matches('ul, ol')) {
|
||||||
|
hideBlockHandle();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compStyle = window.getComputedStyle(node);
|
||||||
|
const lineHeight = parseInt(compStyle.lineHeight, 10);
|
||||||
|
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||||
|
|
||||||
|
const rect = absoluteRect(node);
|
||||||
|
|
||||||
|
rect.top += (lineHeight - 24) / 2;
|
||||||
|
rect.top += paddingTop;
|
||||||
|
// Li markers
|
||||||
|
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
||||||
|
rect.left -= options.blockHandleWidth;
|
||||||
|
}
|
||||||
|
rect.width = options.blockHandleWidth;
|
||||||
|
|
||||||
|
if (!blockHandleElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockHandleElement.style.left = `${
|
||||||
|
rect.left - rect.width - options.blockHandleWidth
|
||||||
|
}px`;
|
||||||
|
blockHandleElement.style.top = `${rect.top}px`;
|
||||||
|
showBlockHandle();
|
||||||
|
};
|
||||||
|
|
||||||
return new Plugin({
|
return new Plugin({
|
||||||
view: (view) => {
|
view: (view) => {
|
||||||
|
// drag handle initialization
|
||||||
dragHandleElement = document.createElement('div');
|
dragHandleElement = document.createElement('div');
|
||||||
dragHandleElement.draggable = true;
|
dragHandleElement.draggable = true;
|
||||||
dragHandleElement.dataset.dragHandle = '';
|
dragHandleElement.dataset.dragHandle = '';
|
||||||
|
dragHandleElement.title = 'Drag to move\nClick to open menu';
|
||||||
dragHandleElement.classList.add('om-drag-handle');
|
dragHandleElement.classList.add('om-drag-handle');
|
||||||
dragHandleElement.addEventListener('dragstart', (e) => {
|
dragHandleElement.addEventListener('dragstart', (e) => {
|
||||||
handleDragStart(e, view);
|
handleDragStart(e, view);
|
||||||
});
|
});
|
||||||
dragHandleElement.addEventListener('click', (e) => {
|
dragHandleElement.addEventListener('click', (e) => {
|
||||||
handleClick(e, view);
|
handleDragClick(e, view);
|
||||||
});
|
});
|
||||||
|
|
||||||
hideDragHandle();
|
hideDragHandle();
|
||||||
|
|
||||||
|
// block handle initialization
|
||||||
|
blockHandleElement = document.createElement('div');
|
||||||
|
blockHandleElement.draggable = false;
|
||||||
|
blockHandleElement.dataset.blockHandle = '';
|
||||||
|
blockHandleElement.title = 'Add new node';
|
||||||
|
blockHandleElement.classList.add('om-block-handle');
|
||||||
|
|
||||||
|
hideBlockHandle();
|
||||||
|
|
||||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||||
|
view?.dom?.parentElement?.appendChild(blockHandleElement);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy: () => {
|
destroy: () => {
|
||||||
dragHandleElement?.remove?.();
|
dragHandleElement?.remove?.();
|
||||||
dragHandleElement = null;
|
dragHandleElement = null;
|
||||||
|
|
||||||
|
blockHandleElement?.remove?.();
|
||||||
|
blockHandleElement = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -167,45 +229,16 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||||||
if (!view.editable) {
|
if (!view.editable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handleMouseMoveForDragHandle(event);
|
||||||
const node = nodeDOMAtCoords({
|
handleMouseMoveForBlockHandle(event);
|
||||||
x: event.clientX + 50 + options.dragHandleWidth,
|
|
||||||
y: event.clientY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!(node instanceof Element) || node.matches('ul, ol')) {
|
|
||||||
hideDragHandle();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const compStyle = window.getComputedStyle(node);
|
|
||||||
const lineHeight = parseInt(compStyle.lineHeight, 10);
|
|
||||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
|
||||||
|
|
||||||
const rect = absoluteRect(node);
|
|
||||||
|
|
||||||
rect.top += (lineHeight - 24) / 2;
|
|
||||||
rect.top += paddingTop;
|
|
||||||
// Li markers
|
|
||||||
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
|
||||||
rect.left -= options.dragHandleWidth;
|
|
||||||
}
|
|
||||||
rect.width = options.dragHandleWidth;
|
|
||||||
|
|
||||||
if (!dragHandleElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
|
||||||
dragHandleElement.style.top = `${rect.top}px`;
|
|
||||||
showDragHandle();
|
|
||||||
},
|
},
|
||||||
keydown: () => {
|
keydown: () => {
|
||||||
hideDragHandle();
|
hideDragHandle();
|
||||||
|
hideBlockHandle();
|
||||||
},
|
},
|
||||||
mousewheel: () => {
|
mousewheel: () => {
|
||||||
hideDragHandle();
|
hideDragHandle();
|
||||||
|
hideBlockHandle();
|
||||||
},
|
},
|
||||||
// dragging class is used for CSS
|
// dragging class is used for CSS
|
||||||
dragstart: (view) => {
|
dragstart: (view) => {
|
||||||
@ -221,20 +254,3 @@ const DragHandle = (options: DragHandleOptions) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
||||||
interface DragAndDropOptions {}
|
|
||||||
|
|
||||||
const DragAndDrop = Extension.create<DragAndDropOptions>({
|
|
||||||
name: 'dragAndDrop',
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
DragHandle({
|
|
||||||
dragHandleWidth: 24,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default DragAndDrop;
|
|
@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Collate.
|
||||||
|
* Licensed under the Apache 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
|
||||||
|
* http://www.apache.org/licenses/LICENSE-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 { EditorView } from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
export const absoluteRect = (node: Element) => {
|
||||||
|
const data = node.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: data.top,
|
||||||
|
left: data.left,
|
||||||
|
width: data.width,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||||
|
return document
|
||||||
|
.elementsFromPoint(coords.x, coords.y)
|
||||||
|
.find(
|
||||||
|
(elem: Element) =>
|
||||||
|
elem.parentElement?.matches?.('.ProseMirror') ||
|
||||||
|
elem.matches(
|
||||||
|
[
|
||||||
|
'li',
|
||||||
|
'p:not(:first-child)',
|
||||||
|
'pre',
|
||||||
|
'blockquote',
|
||||||
|
'h1, h2, h3, h4, h5, h6',
|
||||||
|
].join(', ')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nodePosAtDOM = (node: Element, view: EditorView) => {
|
||||||
|
const boundingRect = node.getBoundingClientRect();
|
||||||
|
|
||||||
|
return view.posAtCoords({
|
||||||
|
left: boundingRect.left + 1,
|
||||||
|
top: boundingRect.top + 1,
|
||||||
|
})?.inside;
|
||||||
|
};
|
@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Collate.
|
||||||
|
* Licensed under the Apache 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
|
||||||
|
* http://www.apache.org/licenses/LICENSE-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 { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
export interface FocusOptions {
|
||||||
|
className: string;
|
||||||
|
mode: 'all' | 'deepest' | 'shallowest';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Focus = Extension.create<FocusOptions>({
|
||||||
|
name: 'focus',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
className: 'has-focus',
|
||||||
|
mode: 'all',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('focus'),
|
||||||
|
props: {
|
||||||
|
decorations: ({ doc, selection }) => {
|
||||||
|
const { isEditable, isFocused } = this.editor;
|
||||||
|
const { anchor } = selection;
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
|
||||||
|
if (!isEditable || !isFocused) {
|
||||||
|
return DecorationSet.create(doc, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maximum Levels
|
||||||
|
let maxLevels = 0;
|
||||||
|
|
||||||
|
if (this.options.mode === 'deepest') {
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (node.isText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrent =
|
||||||
|
anchor >= pos && anchor <= pos + node.nodeSize - 1;
|
||||||
|
|
||||||
|
if (!isCurrent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLevels += 1;
|
||||||
|
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop through current
|
||||||
|
let currentLevel = 0;
|
||||||
|
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (node.isText) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrent =
|
||||||
|
anchor >= pos && anchor <= pos + node.nodeSize - 1;
|
||||||
|
|
||||||
|
if (!isCurrent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel += 1;
|
||||||
|
|
||||||
|
const outOfScope =
|
||||||
|
(this.options.mode === 'deepest' &&
|
||||||
|
maxLevels - currentLevel > 0) ||
|
||||||
|
(this.options.mode === 'shallowest' && currentLevel > 1);
|
||||||
|
|
||||||
|
if (outOfScope) {
|
||||||
|
return this.options.mode === 'deepest';
|
||||||
|
}
|
||||||
|
|
||||||
|
decorations.push(
|
||||||
|
Decoration.node(pos, pos + node.nodeSize, {
|
||||||
|
class: this.options.className,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
@ -15,8 +15,9 @@ import Placeholder from '@tiptap/extension-placeholder';
|
|||||||
import TaskItem from '@tiptap/extension-task-item';
|
import TaskItem from '@tiptap/extension-task-item';
|
||||||
import TaskList from '@tiptap/extension-task-list';
|
import TaskList from '@tiptap/extension-task-list';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import BlockAndDragDrop from './BlockAndDragDrop/BlockAndDragDrop';
|
||||||
import DiffView from './diff-view';
|
import DiffView from './diff-view';
|
||||||
import DragAndDrop from './drag-and-drop';
|
import { Focus } from './focus';
|
||||||
import { Hashtag } from './hashtag';
|
import { Hashtag } from './hashtag';
|
||||||
import { hashtagSuggestion } from './hashtag/hashtagSuggestion';
|
import { hashtagSuggestion } from './hashtag/hashtagSuggestion';
|
||||||
import { Image } from './image/image';
|
import { Image } from './image/image';
|
||||||
@ -102,5 +103,8 @@ export const extensions = [
|
|||||||
allowBase64: true,
|
allowBase64: true,
|
||||||
inline: true,
|
inline: true,
|
||||||
}),
|
}),
|
||||||
DragAndDrop,
|
BlockAndDragDrop,
|
||||||
|
Focus.configure({
|
||||||
|
mode: 'deepest',
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
@ -14,6 +14,8 @@ import { Extension } from '@tiptap/core';
|
|||||||
import { PluginKey } from '@tiptap/pm/state';
|
import { PluginKey } from '@tiptap/pm/state';
|
||||||
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion';
|
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion';
|
||||||
|
|
||||||
|
export const slashMenuPluginKey = new PluginKey('slashSuggestion');
|
||||||
|
|
||||||
export default Extension.create({
|
export default Extension.create({
|
||||||
name: 'slashCommand',
|
name: 'slashCommand',
|
||||||
|
|
||||||
@ -33,7 +35,7 @@ export default Extension.create({
|
|||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
Suggestion({
|
Suggestion({
|
||||||
pluginKey: new PluginKey('slashSuggestion'),
|
pluginKey: slashMenuPluginKey,
|
||||||
...this.options.slashSuggestion,
|
...this.options.slashSuggestion,
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
}),
|
}),
|
||||||
|
@ -17,8 +17,12 @@
|
|||||||
@markdown-bg-color: #f8f8fa;
|
@markdown-bg-color: #f8f8fa;
|
||||||
|
|
||||||
.block-editor-wrapper {
|
.block-editor-wrapper {
|
||||||
|
.om-block-editor > p:last-child {
|
||||||
|
// this is to have enough space after last node, referred from the reference editor
|
||||||
|
padding-bottom: 30vh;
|
||||||
|
}
|
||||||
// show placeholder when editor is in focused mode
|
// show placeholder when editor is in focused mode
|
||||||
.tiptap .is-node-empty:last-child::before {
|
.tiptap.ProseMirror-focused .is-node-empty.has-focus::before {
|
||||||
color: @grey-3;
|
color: @grey-3;
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
@ -250,7 +254,8 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.om-drag-handle {
|
.om-drag-handle,
|
||||||
|
.om-block-handle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: opacity ease-in 0.2s;
|
transition: opacity ease-in 0.2s;
|
||||||
@ -282,12 +287,18 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.om-block-handle {
|
||||||
|
cursor: pointer;
|
||||||
|
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KCjwhLS0gTGljZW5zZTogTUlULiBNYWRlIGJ5IGRldmVsb3BtZW50c2VlZDogaHR0cHM6Ly9naXRodWIuY29tL2RldmVsb3BtZW50c2VlZC9jb2xsZWN0aWNvbnMtbGliIC0tPgo8c3ZnIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDE2IDE2IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIGZpbGw9IiM3OTc5NzkiPgogIDxyZWN0IHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgaWQ9Imljb24tYm91bmQiIGZpbGw9Im5vbmUiIC8+CiAgPHBvbHlnb24gcG9pbnRzPSIxNSw3IDksNyA5LDEgNywxIDcsNyAxLDcgMSw5IDcsOSA3LDE1IDksMTUgOSw5IDE1LDkiIC8+Cjwvc3ZnPg==');
|
||||||
|
}
|
||||||
|
|
||||||
.om-list-decimal {
|
.om-list-decimal {
|
||||||
list-style-type: decimal !important;
|
list-style-type: decimal !important;
|
||||||
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
.om-list-disc {
|
.om-list-disc {
|
||||||
list-style-type: disc !important;
|
list-style-type: disc !important;
|
||||||
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
.om-leading-normal {
|
.om-leading-normal {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
@ -331,6 +331,7 @@
|
|||||||
"domain-plural": "Bereiche",
|
"domain-plural": "Bereiche",
|
||||||
"domain-type": "Bereichstyp",
|
"domain-type": "Bereichstyp",
|
||||||
"downstream-depth": "Nachgelagerte Tiefe",
|
"downstream-depth": "Nachgelagerte Tiefe",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "Dauer",
|
"duration": "Dauer",
|
||||||
"edge": "Kante",
|
"edge": "Kante",
|
||||||
"edge-information": "Kanteninformationen",
|
"edge-information": "Kanteninformationen",
|
||||||
|
@ -331,6 +331,7 @@
|
|||||||
"domain-plural": "Domains",
|
"domain-plural": "Domains",
|
||||||
"domain-type": "Domain Type",
|
"domain-type": "Domain Type",
|
||||||
"downstream-depth": "Downstream Depth",
|
"downstream-depth": "Downstream Depth",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"edge": "Edge",
|
"edge": "Edge",
|
||||||
"edge-information": "Edge Information",
|
"edge-information": "Edge Information",
|
||||||
|
@ -331,6 +331,7 @@
|
|||||||
"domain-plural": "Domains",
|
"domain-plural": "Domains",
|
||||||
"domain-type": "Domain Type",
|
"domain-type": "Domain Type",
|
||||||
"downstream-depth": "Profundidad del flujo",
|
"downstream-depth": "Profundidad del flujo",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"edge": "Edge",
|
"edge": "Edge",
|
||||||
"edge-information": "Información de la arista",
|
"edge-information": "Información de la arista",
|
||||||
|
@ -331,6 +331,7 @@
|
|||||||
"domain-plural": "Domaines",
|
"domain-plural": "Domaines",
|
||||||
"domain-type": "Type de Domaine",
|
"domain-type": "Type de Domaine",
|
||||||
"downstream-depth": "Profondeur en Aval",
|
"downstream-depth": "Profondeur en Aval",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "Durée",
|
"duration": "Durée",
|
||||||
"edge": "Bord",
|
"edge": "Bord",
|
||||||
"edge-information": "Informations du Bord",
|
"edge-information": "Informations du Bord",
|
||||||
|
@ -331,6 +331,7 @@
|
|||||||
"domain-plural": "Domains",
|
"domain-plural": "Domains",
|
||||||
"domain-type": "Domain Type",
|
"domain-type": "Domain Type",
|
||||||
"downstream-depth": "Downstream Depth",
|
"downstream-depth": "Downstream Depth",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"edge": "Edge",
|
"edge": "Edge",
|
||||||
"edge-information": "エッジの情報",
|
"edge-information": "エッジの情報",
|
||||||
|
@ -331,6 +331,7 @@
|
|||||||
"domain-plural": "Domains",
|
"domain-plural": "Domains",
|
||||||
"domain-type": "Domain Type",
|
"domain-type": "Domain Type",
|
||||||
"downstream-depth": "Profundidade abaixo",
|
"downstream-depth": "Profundidade abaixo",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"edge": "Edge",
|
"edge": "Edge",
|
||||||
"edge-information": "Informação sobre o limite",
|
"edge-information": "Informação sobre o limite",
|
||||||
|
@ -331,6 +331,7 @@
|
|||||||
"domain-plural": "Domains",
|
"domain-plural": "Domains",
|
||||||
"domain-type": "Domain Type",
|
"domain-type": "Domain Type",
|
||||||
"downstream-depth": "Нисходящая линия",
|
"downstream-depth": "Нисходящая линия",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "Длительность",
|
"duration": "Длительность",
|
||||||
"edge": "Edge",
|
"edge": "Edge",
|
||||||
"edge-information": "Граница информации",
|
"edge-information": "Граница информации",
|
||||||
|
@ -331,6 +331,7 @@
|
|||||||
"domain-plural": "域",
|
"domain-plural": "域",
|
||||||
"domain-type": "域类型",
|
"domain-type": "域类型",
|
||||||
"downstream-depth": "下游深度",
|
"downstream-depth": "下游深度",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "持续时间",
|
"duration": "持续时间",
|
||||||
"edge": "Edge",
|
"edge": "Edge",
|
||||||
"edge-information": "连线信息",
|
"edge-information": "连线信息",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user