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:
Sachin Chaurasiya 2023-10-21 20:57:04 +05:30 committed by GitHub
parent 0fee1ccb3a
commit 32ac45f11f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 602 additions and 105 deletions

View File

@ -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

View File

@ -49,6 +49,11 @@ const BlockEditor = forwardRef<BlockEditorRef, BlockEditorProps>(
onChange?.(backendFormat);
},
editorProps: {
attributes: {
class: 'om-block-editor',
},
},
});
useImperativeHandle(ref, () => ({

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -15,6 +15,7 @@ import { isEmpty, isNil } from 'lodash';
import React, { forwardRef, useImperativeHandle, useState } from 'react';
import tippy, { Instance, Props } from 'tippy.js';
import { EditorSlotsRef } from './BlockEditor.interface';
import BlockMenu from './BlockMenu/BlockMenu';
import BubbleMenu from './BubbleMenu/BubbleMenu';
import LinkModal, { LinkData } from './LinkModal/LinkModal';
import LinkPopup from './LinkPopup/LinkPopup';
@ -164,6 +165,7 @@ const EditorSlots = forwardRef<EditorSlotsRef, EditorSlotsProps>(
/>
)}
{menus}
{!isNil(editor) && <BlockMenu editor={editor} />}
</>
);
}

View File

@ -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;

View File

@ -10,57 +10,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Extension } from '@tiptap/core';
import { NodeSelection, Plugin } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { EditorView, __serializeForClipboard } from '@tiptap/pm/view';
import { isUndefined } from 'lodash';
import { BlockAndDragHandleOptions } from './BlockAndDragDrop';
import { absoluteRect, nodeDOMAtCoords, nodePosAtDOM } from './helpers';
export interface DragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
}
const absoluteRect = (node: Element) => {
const data = node.getBoundingClientRect();
export const BlockAndDragHandle = (options: BlockAndDragHandleOptions) => {
let dragHandleElement: HTMLElement | null = null;
let blockHandleElement: HTMLElement | null = null;
return {
top: data.top,
left: data.left,
width: data.width,
};
};
// Drag Handle handlers
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) => {
view.focus();
@ -78,7 +41,7 @@ const DragHandle = (options: DragHandleOptions) => {
}
const nodePos = nodePosAtDOM(node, view);
if (nodePos == null || nodePos < 0) {
if (isUndefined(nodePos)) {
return;
}
@ -99,7 +62,7 @@ const DragHandle = (options: DragHandleOptions) => {
view.dragging = { slice, move: event.ctrlKey };
};
const handleClick = (event: MouseEvent, view: EditorView) => {
const handleDragClick = (event: MouseEvent, view: EditorView) => {
view.focus();
view.dom.classList.remove('om-node-dragging');
@ -114,7 +77,7 @@ const DragHandle = (options: DragHandleOptions) => {
}
const nodePos = nodePosAtDOM(node, view);
if (!nodePos) {
if (isUndefined(nodePos)) {
return;
}
@ -123,8 +86,6 @@ const DragHandle = (options: DragHandleOptions) => {
);
};
let dragHandleElement: HTMLElement | null = null;
const hideDragHandle = () => {
if (dragHandleElement) {
dragHandleElement.classList.add('hidden');
@ -137,37 +98,7 @@ const DragHandle = (options: DragHandleOptions) => {
}
};
return new Plugin({
view: (view) => {
dragHandleElement = document.createElement('div');
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = '';
dragHandleElement.classList.add('om-drag-handle');
dragHandleElement.addEventListener('dragstart', (e) => {
handleDragStart(e, view);
});
dragHandleElement.addEventListener('click', (e) => {
handleClick(e, view);
});
hideDragHandle();
view?.dom?.parentElement?.appendChild(dragHandleElement);
return {
destroy: () => {
dragHandleElement?.remove?.();
dragHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const handleMouseMoveForDragHandle = (event: MouseEvent) => {
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
@ -200,12 +131,114 @@ const DragHandle = (options: DragHandleOptions) => {
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({
view: (view) => {
// drag handle initialization
dragHandleElement = document.createElement('div');
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = '';
dragHandleElement.title = 'Drag to move\nClick to open menu';
dragHandleElement.classList.add('om-drag-handle');
dragHandleElement.addEventListener('dragstart', (e) => {
handleDragStart(e, view);
});
dragHandleElement.addEventListener('click', (e) => {
handleDragClick(e, view);
});
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(blockHandleElement);
return {
destroy: () => {
dragHandleElement?.remove?.();
dragHandleElement = null;
blockHandleElement?.remove?.();
blockHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
handleMouseMoveForDragHandle(event);
handleMouseMoveForBlockHandle(event);
},
keydown: () => {
hideDragHandle();
hideBlockHandle();
},
mousewheel: () => {
hideDragHandle();
hideBlockHandle();
},
// dragging class is used for CSS
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;

View File

@ -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;
};

View File

@ -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);
},
},
}),
];
},
});

View File

@ -15,8 +15,9 @@ import Placeholder from '@tiptap/extension-placeholder';
import TaskItem from '@tiptap/extension-task-item';
import TaskList from '@tiptap/extension-task-list';
import StarterKit from '@tiptap/starter-kit';
import BlockAndDragDrop from './BlockAndDragDrop/BlockAndDragDrop';
import DiffView from './diff-view';
import DragAndDrop from './drag-and-drop';
import { Focus } from './focus';
import { Hashtag } from './hashtag';
import { hashtagSuggestion } from './hashtag/hashtagSuggestion';
import { Image } from './image/image';
@ -102,5 +103,8 @@ export const extensions = [
allowBase64: true,
inline: true,
}),
DragAndDrop,
BlockAndDragDrop,
Focus.configure({
mode: 'deepest',
}),
];

View File

@ -14,6 +14,8 @@ import { Extension } from '@tiptap/core';
import { PluginKey } from '@tiptap/pm/state';
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion';
export const slashMenuPluginKey = new PluginKey('slashSuggestion');
export default Extension.create({
name: 'slashCommand',
@ -33,7 +35,7 @@ export default Extension.create({
addProseMirrorPlugins() {
return [
Suggestion({
pluginKey: new PluginKey('slashSuggestion'),
pluginKey: slashMenuPluginKey,
...this.options.slashSuggestion,
editor: this.editor,
}),

View File

@ -17,8 +17,12 @@
@markdown-bg-color: #f8f8fa;
.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
.tiptap .is-node-empty:last-child::before {
.tiptap.ProseMirror-focused .is-node-empty.has-focus::before {
color: @grey-3;
content: attr(data-placeholder);
float: left;
@ -250,7 +254,8 @@
box-shadow: none;
}
.om-drag-handle {
.om-drag-handle,
.om-block-handle {
position: fixed;
opacity: 1;
transition: opacity ease-in 0.2s;
@ -282,12 +287,18 @@
pointer-events: none;
}
}
.om-block-handle {
cursor: pointer;
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KCjwhLS0gTGljZW5zZTogTUlULiBNYWRlIGJ5IGRldmVsb3BtZW50c2VlZDogaHR0cHM6Ly9naXRodWIuY29tL2RldmVsb3BtZW50c2VlZC9jb2xsZWN0aWNvbnMtbGliIC0tPgo8c3ZnIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDE2IDE2IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIGZpbGw9IiM3OTc5NzkiPgogIDxyZWN0IHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgaWQ9Imljb24tYm91bmQiIGZpbGw9Im5vbmUiIC8+CiAgPHBvbHlnb24gcG9pbnRzPSIxNSw3IDksNyA5LDEgNywxIDcsNyAxLDcgMSw5IDcsOSA3LDE1IDksMTUgOSw5IDE1LDkiIC8+Cjwvc3ZnPg==');
}
.om-list-decimal {
list-style-type: decimal !important;
padding-left: 16px;
}
.om-list-disc {
list-style-type: disc !important;
padding-left: 16px;
}
.om-leading-normal {
line-height: 1.5;

View File

@ -331,6 +331,7 @@
"domain-plural": "Bereiche",
"domain-type": "Bereichstyp",
"downstream-depth": "Nachgelagerte Tiefe",
"duplicate": "Duplicate",
"duration": "Dauer",
"edge": "Kante",
"edge-information": "Kanteninformationen",

View File

@ -331,6 +331,7 @@
"domain-plural": "Domains",
"domain-type": "Domain Type",
"downstream-depth": "Downstream Depth",
"duplicate": "Duplicate",
"duration": "Duration",
"edge": "Edge",
"edge-information": "Edge Information",

View File

@ -331,6 +331,7 @@
"domain-plural": "Domains",
"domain-type": "Domain Type",
"downstream-depth": "Profundidad del flujo",
"duplicate": "Duplicate",
"duration": "Duration",
"edge": "Edge",
"edge-information": "Información de la arista",

View File

@ -331,6 +331,7 @@
"domain-plural": "Domaines",
"domain-type": "Type de Domaine",
"downstream-depth": "Profondeur en Aval",
"duplicate": "Duplicate",
"duration": "Durée",
"edge": "Bord",
"edge-information": "Informations du Bord",

View File

@ -331,6 +331,7 @@
"domain-plural": "Domains",
"domain-type": "Domain Type",
"downstream-depth": "Downstream Depth",
"duplicate": "Duplicate",
"duration": "Duration",
"edge": "Edge",
"edge-information": "エッジの情報",

View File

@ -331,6 +331,7 @@
"domain-plural": "Domains",
"domain-type": "Domain Type",
"downstream-depth": "Profundidade abaixo",
"duplicate": "Duplicate",
"duration": "Duration",
"edge": "Edge",
"edge-information": "Informação sobre o limite",

View File

@ -331,6 +331,7 @@
"domain-plural": "Domains",
"domain-type": "Domain Type",
"downstream-depth": "Нисходящая линия",
"duplicate": "Duplicate",
"duration": "Длительность",
"edge": "Edge",
"edge-information": "Граница информации",

View File

@ -331,6 +331,7 @@
"domain-plural": "域",
"domain-type": "域类型",
"downstream-depth": "下游深度",
"duplicate": "Duplicate",
"duration": "持续时间",
"edge": "Edge",
"edge-information": "连线信息",