Fix: image insertion in node boundaries and missing extensions (#4977)
* fix: insertion of image at node boundaries fixed * fix: remove unecessary things * fix: history conflicting * fix: generate json from html properly
This commit is contained in:
parent
96563b438e
commit
7d4bb3e12b
16 changed files with 176 additions and 163 deletions
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
|
||||||
|
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@/extensions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description return an object with contentJSON and editorSchema
|
||||||
|
* @description contentJSON- ProseMirror JSON from HTML content
|
||||||
|
* @description editorSchema- editor schema from extensions
|
||||||
|
* @param {string} html
|
||||||
|
* @returns {object} {contentJSON, editorSchema}
|
||||||
|
*/
|
||||||
|
export const generateJSONfromHTMLForDocumentEditor = (html: string) => {
|
||||||
|
const extensions = [...CoreEditorExtensionsWithoutProps(), ...DocumentEditorExtensionsWithoutProps()];
|
||||||
|
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
|
||||||
|
const editorSchema = getSchema(extensions as Extensions);
|
||||||
|
return {
|
||||||
|
contentJSON,
|
||||||
|
editorSchema,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./editor";
|
export * from "./editor";
|
||||||
export * from "./page-renderer";
|
export * from "./page-renderer";
|
||||||
export * from "./read-only-editor";
|
export * from "./read-only-editor";
|
||||||
|
export * from "./helpers";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
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 TextStyle from "@tiptap/extension-text-style";
|
import TextStyle from "@tiptap/extension-text-style";
|
||||||
|
|
@ -46,10 +45,7 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
horizontalRule: false,
|
horizontalRule: false,
|
||||||
blockquote: false,
|
blockquote: false,
|
||||||
dropcursor: {
|
dropcursor: false,
|
||||||
color: "rgba(var(--color-text-100))",
|
|
||||||
width: 1,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
CustomQuoteExtension,
|
CustomQuoteExtension,
|
||||||
CustomHorizontalRule.configure({
|
CustomHorizontalRule.configure({
|
||||||
|
|
@ -58,7 +54,6 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
CustomKeymap,
|
CustomKeymap,
|
||||||
// ListKeymap,
|
|
||||||
CustomLinkExtension.configure({
|
CustomLinkExtension.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
autolink: true,
|
autolink: true,
|
||||||
|
|
@ -105,16 +100,4 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
CustomMentionWithoutProps(),
|
CustomMentionWithoutProps(),
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: ({ editor, node }) => {
|
|
||||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
|
||||||
|
|
||||||
const shouldHidePlaceholder =
|
|
||||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
|
||||||
if (shouldHidePlaceholder) return "";
|
|
||||||
|
|
||||||
return "Press '/' for commands...";
|
|
||||||
},
|
|
||||||
includeChildren: true,
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { IssueWidgetWithoutProps } from "@/extensions/issue-embed";
|
||||||
|
|
||||||
|
export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()];
|
||||||
|
|
@ -70,6 +70,7 @@ export const CoreEditorExtensions = ({
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
horizontalRule: false,
|
horizontalRule: false,
|
||||||
blockquote: false,
|
blockquote: false,
|
||||||
|
history: false,
|
||||||
dropcursor: {
|
dropcursor: {
|
||||||
color: "rgba(var(--color-text-100))",
|
color: "rgba(var(--color-text-100))",
|
||||||
width: 1,
|
width: 1,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import ImageExt from "@tiptap/extension-image";
|
import ImageExt from "@tiptap/extension-image";
|
||||||
|
// helpers
|
||||||
|
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||||
// plugins
|
// plugins
|
||||||
import {
|
import {
|
||||||
IMAGE_NODE_TYPE,
|
IMAGE_NODE_TYPE,
|
||||||
|
|
@ -9,16 +11,13 @@ import {
|
||||||
} from "@/plugins/image";
|
} from "@/plugins/image";
|
||||||
// types
|
// types
|
||||||
import { DeleteImage, RestoreImage } from "@/types";
|
import { DeleteImage, RestoreImage } from "@/types";
|
||||||
// helpers
|
|
||||||
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
|
||||||
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
|
||||||
|
|
||||||
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
||||||
ImageExt.extend<any, ImageExtensionStorage>({
|
ImageExt.extend<any, ImageExtensionStorage>({
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
ArrowDown: insertLineBelowImageAction,
|
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"),
|
||||||
ArrowUp: insertLineAboveImageAction,
|
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,7 @@
|
||||||
import ImageExt from "@tiptap/extension-image";
|
import ImageExt from "@tiptap/extension-image";
|
||||||
// helpers
|
|
||||||
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
|
||||||
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
|
||||||
|
|
||||||
export const ImageExtensionWithoutProps = () =>
|
export const ImageExtensionWithoutProps = () =>
|
||||||
ImageExt.extend({
|
ImageExt.extend({
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
ArrowDown: insertLineBelowImageAction,
|
|
||||||
ArrowUp: insertLineAboveImageAction,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// storage to keep track of image states Map<src, isDeleted>
|
|
||||||
addStorage() {
|
|
||||||
return {
|
|
||||||
images: new Map<string, boolean>(),
|
|
||||||
uploadInProgress: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
|
||||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
|
||||||
|
|
||||||
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
|
||||||
try {
|
|
||||||
const { selection, doc } = editor.state;
|
|
||||||
const { $from, $to } = selection;
|
|
||||||
|
|
||||||
let imageNode: ProseMirrorNode | null = null;
|
|
||||||
let imagePos: number | null = null;
|
|
||||||
|
|
||||||
// Check if the selection itself is an image node
|
|
||||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
|
||||||
if (node.type.name === "image") {
|
|
||||||
imageNode = node;
|
|
||||||
imagePos = pos;
|
|
||||||
return false; // Stop iterating once an image node is found
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (imageNode === null || imagePos === null) return false;
|
|
||||||
|
|
||||||
// Since we want to insert above the image, we use the imagePos directly
|
|
||||||
const insertPos = imagePos;
|
|
||||||
|
|
||||||
const docSize = editor.state.doc.content.size;
|
|
||||||
|
|
||||||
if (insertPos < 0 || insertPos > docSize) return false;
|
|
||||||
|
|
||||||
// Check for an existing node immediately before the image
|
|
||||||
if (insertPos === 0) {
|
|
||||||
// If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there
|
|
||||||
editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run();
|
|
||||||
editor.chain().setTextSelection(insertPos).run();
|
|
||||||
} else {
|
|
||||||
const prevNode = doc.nodeAt(insertPos);
|
|
||||||
|
|
||||||
if (prevNode && prevNode.type.name === "paragraph") {
|
|
||||||
// If the previous node is a paragraph, move the cursor there
|
|
||||||
editor.chain().setTextSelection(insertPos).run();
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("An error occurred while inserting a line above the image:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
|
||||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
|
||||||
|
|
||||||
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
|
||||||
try {
|
|
||||||
const { selection, doc } = editor.state;
|
|
||||||
const { $from, $to } = selection;
|
|
||||||
|
|
||||||
let imageNode: ProseMirrorNode | null = null;
|
|
||||||
let imagePos: number | null = null;
|
|
||||||
|
|
||||||
// Check if the selection itself is an image node
|
|
||||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
|
||||||
if (node.type.name === "image") {
|
|
||||||
imageNode = node;
|
|
||||||
imagePos = pos;
|
|
||||||
return false; // Stop iterating once an image node is found
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (imageNode === null || imagePos === null) return false;
|
|
||||||
|
|
||||||
const guaranteedImageNode: ProseMirrorNode = imageNode;
|
|
||||||
const nextNodePos = imagePos + guaranteedImageNode.nodeSize;
|
|
||||||
|
|
||||||
// Check for an existing node immediately after the image
|
|
||||||
const nextNode = doc.nodeAt(nextNodePos);
|
|
||||||
|
|
||||||
if (nextNode && nextNode.type.name === "paragraph") {
|
|
||||||
// If the next node is a paragraph, move the cursor there
|
|
||||||
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
|
||||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
|
||||||
} else if (!nextNode) {
|
|
||||||
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
|
||||||
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.setTextSelection(nextNodePos + 1)
|
|
||||||
.run();
|
|
||||||
} else {
|
|
||||||
// If the next node is not a paragraph, do not proceed
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("An error occurred while inserting a line below the image:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -8,6 +8,7 @@ export * from "./mentions";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
export * from "./typography";
|
export * from "./typography";
|
||||||
export * from "./core-without-props";
|
export * from "./core-without-props";
|
||||||
|
export * from "./document-without-props";
|
||||||
export * from "./custom-code-inline";
|
export * from "./custom-code-inline";
|
||||||
export * from "./drag-drop";
|
export * from "./drag-drop";
|
||||||
export * from "./drop";
|
export * from "./drop";
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from "./widget-node";
|
export * from "./widget-node";
|
||||||
|
export * from "./issue-embed-without-props";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
|
||||||
|
export const IssueWidgetWithoutProps = () =>
|
||||||
|
Node.create({
|
||||||
|
name: "issue-embed-component",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
entity_identifier: {
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
project_identifier: {
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
workspace_identifier: {
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
entity_name: {
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: "issue-embed-component",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -61,20 +61,3 @@ export const isValidHttpUrl = (string: string): boolean => {
|
||||||
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @description return an object with contentJSON and editorSchema
|
|
||||||
* @description contentJSON- ProseMirror JSON from HTML content
|
|
||||||
* @description editorSchema- editor schema from extensions
|
|
||||||
* @param {string} html
|
|
||||||
* @returns {object} {contentJSON, editorSchema}
|
|
||||||
*/
|
|
||||||
export const generateJSONfromHTML = (html: string) => {
|
|
||||||
const extensions = CoreEditorExtensionsWithoutProps();
|
|
||||||
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
|
|
||||||
const editorSchema = getSchema(extensions as Extensions);
|
|
||||||
return {
|
|
||||||
contentJSON,
|
|
||||||
editorSchema,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||||
|
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
|
|
||||||
|
type Direction = "up" | "down";
|
||||||
|
|
||||||
|
export const insertEmptyParagraphAtNodeBoundaries: (
|
||||||
|
direction: Direction,
|
||||||
|
nodeType: string
|
||||||
|
) => KeyboardShortcutCommand =
|
||||||
|
(direction, nodeType) =>
|
||||||
|
({ editor }) => {
|
||||||
|
try {
|
||||||
|
const { selection, doc } = editor.state;
|
||||||
|
const { $from, $to } = selection;
|
||||||
|
|
||||||
|
let targetNode: ProseMirrorNode | null = null;
|
||||||
|
let targetNodePos: number | null = null;
|
||||||
|
|
||||||
|
// Check if the selection itself is the target node
|
||||||
|
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||||
|
if (node.type.name === nodeType) {
|
||||||
|
targetNode = node;
|
||||||
|
targetNodePos = pos;
|
||||||
|
return false; // Stop iterating once the target node is found
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetNode === null || targetNodePos === null) return false;
|
||||||
|
|
||||||
|
const docSize = doc.content.size; // Get the size of the document
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case "up": {
|
||||||
|
const insertPosUp = targetNodePos;
|
||||||
|
|
||||||
|
// Ensure the insert position is within the document boundaries
|
||||||
|
if (insertPosUp < 0 || insertPosUp > docSize) return false;
|
||||||
|
|
||||||
|
if (insertPosUp === 0) {
|
||||||
|
// If at the very start of the document, insert a new paragraph at the start
|
||||||
|
editor.chain().insertContentAt(insertPosUp, { type: "paragraph" }).run();
|
||||||
|
editor.chain().setTextSelection(insertPosUp).run(); // Set the cursor to the new paragraph
|
||||||
|
} else {
|
||||||
|
// Otherwise, check the node immediately before the target node
|
||||||
|
const prevNode = doc.nodeAt(insertPosUp - 1);
|
||||||
|
|
||||||
|
if (prevNode && prevNode.type.name === "paragraph") {
|
||||||
|
// If the previous node is a paragraph, move the cursor there
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.setTextSelection(insertPosUp - 1)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
return false; // If the previous node is not a paragraph, do not proceed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "down": {
|
||||||
|
const insertPosDown = targetNodePos + (targetNode as ProseMirrorNode).nodeSize;
|
||||||
|
|
||||||
|
// Ensure the insert position is within the document boundaries
|
||||||
|
if (insertPosDown < 0 || insertPosDown > docSize) return false;
|
||||||
|
|
||||||
|
// Check the node immediately after the target node
|
||||||
|
const nextNode = doc.nodeAt(insertPosDown);
|
||||||
|
|
||||||
|
if (nextNode && nextNode.type.name === "paragraph") {
|
||||||
|
// If the next node is a paragraph, move the cursor to the end of it
|
||||||
|
const endOfParagraphPos = insertPosDown + nextNode.nodeSize - 1;
|
||||||
|
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||||
|
} else if (!nextNode) {
|
||||||
|
// If there is no next node (end of document), insert a new paragraph
|
||||||
|
editor.chain().insertContentAt(insertPosDown, { type: "paragraph" }).run();
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.setTextSelection(insertPosDown + 1)
|
||||||
|
.run(); // Set the cursor to the new paragraph
|
||||||
|
} else {
|
||||||
|
return false; // If the next node is not a paragraph, do not proceed
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false; // If the direction is not recognized, do not proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // Return true if the operation was successful
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`An error occurred while inserting a line ${direction} the ${nodeType}:`, error);
|
||||||
|
return false; // Return false if an error occurred
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -19,6 +19,7 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
export * from "@/helpers/common";
|
export * from "@/helpers/common";
|
||||||
|
export * from "@/components/editors/document/helpers";
|
||||||
export * from "@/helpers/editor-commands";
|
export * from "@/helpers/editor-commands";
|
||||||
export * from "@/helpers/yjs";
|
export * from "@/helpers/yjs";
|
||||||
export * from "@/extensions/table/table";
|
export * from "@/extensions/table/table";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import { EditorRefApi, generateJSONfromHTML, proseMirrorJSONToBinaryString, applyUpdates } from "@plane/editor";
|
import {
|
||||||
|
EditorRefApi,
|
||||||
|
proseMirrorJSONToBinaryString,
|
||||||
|
applyUpdates,
|
||||||
|
generateJSONfromHTMLForDocumentEditor,
|
||||||
|
} from "@plane/editor";
|
||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||||
|
|
@ -71,7 +76,7 @@ export const usePageDescription = (props: Props) => {
|
||||||
const changeHTMLToBinary = async () => {
|
const changeHTMLToBinary = async () => {
|
||||||
if (!pageDescriptionYJS || !pageDescription) return;
|
if (!pageDescriptionYJS || !pageDescription) return;
|
||||||
if (pageDescriptionYJS.length === 0) {
|
if (pageDescriptionYJS.length === 0) {
|
||||||
const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "<p></p>");
|
const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(pageDescription ?? "<p></p>");
|
||||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue