forked from github/plane
Compare commits
7 Commits
preview
...
loom-embed
Author | SHA1 | Date | |
---|---|---|---|
|
6a96278cd5 | ||
|
a4171552e1 | ||
|
ce18387600 | ||
|
d8cd0b0f97 | ||
|
2ff953896d | ||
|
d714079c1c | ||
|
5136f3b329 |
@ -1,18 +1,23 @@
|
|||||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||||
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
import {
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
useImperativeHandle,
|
||||||
import { DeleteImage } from '../../types/delete-image';
|
useRef,
|
||||||
|
MutableRefObject,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { DeleteImage } from "../../types/delete-image";
|
||||||
import { CoreEditorProps } from "../props";
|
import { CoreEditorProps } from "../props";
|
||||||
import { CoreEditorExtensions } from "../extensions";
|
import { CoreEditorExtensions } from "../extensions";
|
||||||
import { EditorProps } from '@tiptap/pm/view';
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
import { getTrimmedHTML } from "../../lib/utils";
|
import { getTrimmedHTML } from "../../lib/utils";
|
||||||
import { UploadImage } from "../../types/upload-image";
|
import { UploadImage } from "../../types/upload-image";
|
||||||
|
|
||||||
const DEBOUNCE_DELAY = 1500;
|
|
||||||
|
|
||||||
interface CustomEditorProps {
|
interface CustomEditorProps {
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
setIsSubmitting?: (
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
|
) => void;
|
||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
value: string;
|
value: string;
|
||||||
deleteFile: DeleteImage;
|
deleteFile: DeleteImage;
|
||||||
@ -23,25 +28,51 @@ interface CustomEditorProps {
|
|||||||
forwardedRef?: any;
|
forwardedRef?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, extensions = [], onChange, setIsSubmitting, debouncedUpdatesEnabled, forwardedRef, setShouldShowAlert, }: CustomEditorProps) => {
|
export const useEditor = ({
|
||||||
const editor = useCustomEditor({
|
uploadFile,
|
||||||
editorProps: {
|
deleteFile,
|
||||||
...CoreEditorProps(uploadFile, setIsSubmitting),
|
editorProps = {},
|
||||||
...editorProps,
|
value,
|
||||||
},
|
extensions = [],
|
||||||
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
|
onChange,
|
||||||
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
setIsSubmitting,
|
||||||
onUpdate: async ({ editor }) => {
|
forwardedRef,
|
||||||
// for instant feedback loop
|
setShouldShowAlert,
|
||||||
setIsSubmitting?.("submitting");
|
}: CustomEditorProps) => {
|
||||||
setShouldShowAlert?.(true);
|
const [internalEditorContent, setInternalEditorContent] = useState(value);
|
||||||
if (debouncedUpdatesEnabled) {
|
const editor = useCustomEditor(
|
||||||
debouncedUpdates({ onChange: onChange, editor });
|
{
|
||||||
} else {
|
editorProps: {
|
||||||
|
...CoreEditorProps(uploadFile, setIsSubmitting),
|
||||||
|
...editorProps,
|
||||||
|
},
|
||||||
|
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
|
||||||
|
content:
|
||||||
|
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||||
|
onUpdate: async ({ editor }) => {
|
||||||
|
// for instant feedback loop
|
||||||
|
setIsSubmitting?.("submitting");
|
||||||
|
setShouldShowAlert?.(true);
|
||||||
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
});
|
[internalEditorContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasIntiliazedContent = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor) {
|
||||||
|
const cleanedValue =
|
||||||
|
typeof value === "string" && value.trim() !== "" ? value : "<p></p>";
|
||||||
|
if (cleanedValue !== "<p></p>" && !hasIntiliazedContent.current) {
|
||||||
|
setInternalEditorContent(cleanedValue);
|
||||||
|
hasIntiliazedContent.current = true;
|
||||||
|
} else if (cleanedValue === "<p></p>" && hasIntiliazedContent.current) {
|
||||||
|
hasIntiliazedContent.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, editor]);
|
||||||
|
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
@ -55,12 +86,6 @@ export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, ext
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
|
||||||
if (onChange) {
|
|
||||||
onChange(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
|
||||||
}
|
|
||||||
}, DEBOUNCE_DELAY);
|
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||||
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
import {
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
MutableRefObject,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
||||||
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
||||||
import { EditorProps } from '@tiptap/pm/view';
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
|
|
||||||
interface CustomReadOnlyEditorProps {
|
interface CustomReadOnlyEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@ -11,10 +16,16 @@ interface CustomReadOnlyEditorProps {
|
|||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {} }: CustomReadOnlyEditorProps) => {
|
export const useReadOnlyEditor = ({
|
||||||
|
value,
|
||||||
|
forwardedRef,
|
||||||
|
extensions = [],
|
||||||
|
editorProps = {},
|
||||||
|
}: CustomReadOnlyEditorProps) => {
|
||||||
const editor = useCustomEditor({
|
const editor = useCustomEditor({
|
||||||
editable: false,
|
editable: false,
|
||||||
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
content:
|
||||||
|
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||||
editorProps: {
|
editorProps: {
|
||||||
...CoreReadOnlyEditorProps,
|
...CoreReadOnlyEditorProps,
|
||||||
...editorProps,
|
...editorProps,
|
||||||
@ -22,6 +33,14 @@ export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editor
|
|||||||
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
|
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasIntiliazedContent = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && !value && !hasIntiliazedContent.current) {
|
||||||
|
editor.commands.setContent(value);
|
||||||
|
hasIntiliazedContent.current = true;
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
|
|
||||||
@ -34,7 +53,6 @@ export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editor
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
import ListItem from '@tiptap/extension-list-item'
|
|
||||||
|
|
||||||
export const CustomListItem = ListItem.extend({
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
'Shift-Enter': () => this.editor.chain().focus().splitListItem('listItem').run(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
@ -1,16 +1,25 @@
|
|||||||
import { Extension } from '@tiptap/core';
|
import { Extension } from "@tiptap/core";
|
||||||
|
|
||||||
export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({
|
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||||
name: 'enterKey',
|
Extension.create({
|
||||||
|
name: "enterKey",
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
'Enter': () => {
|
Enter: () => {
|
||||||
if (onEnterKeyPress) {
|
if (onEnterKeyPress) {
|
||||||
onEnterKeyPress();
|
onEnterKeyPress();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
}
|
"Shift-Enter": ({ editor }) =>
|
||||||
},
|
editor.commands.first(({ commands }) => [
|
||||||
});
|
() => commands.newlineInCode(),
|
||||||
|
() => commands.splitListItem("listItem"),
|
||||||
|
() => commands.createParagraphNear(),
|
||||||
|
() => commands.liftEmptyBlock(),
|
||||||
|
() => commands.splitBlock(),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { CustomListItem } from "./custom-list-extension";
|
|
||||||
import { EnterKeyExtension } from "./enter-key-extension";
|
import { EnterKeyExtension } from "./enter-key-extension";
|
||||||
|
|
||||||
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
|
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
|
||||||
CustomListItem,
|
|
||||||
EnterKeyExtension(onEnterKeyPress),
|
EnterKeyExtension(onEnterKeyPress),
|
||||||
];
|
];
|
||||||
|
76
packages/editor/rich-text-editor/src/ui/extensions/embed.ts
Normal file
76
packages/editor/rich-text-editor/src/ui/extensions/embed.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Node } from '@tiptap/core'
|
||||||
|
|
||||||
|
export interface IframeOptions {
|
||||||
|
allowFullscreen: boolean,
|
||||||
|
HTMLAttributes: {
|
||||||
|
[key: string]: any
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
iframe: {
|
||||||
|
/**
|
||||||
|
* Add an iframe
|
||||||
|
*/
|
||||||
|
setIframe: (options: { src: string }) => ReturnType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmbedExtension = Node.create<IframeOptions>({
|
||||||
|
name: 'iframe',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
allowFullscreen: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'iframe-wrapper',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
frameborder: {
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
allowfullscreen: {
|
||||||
|
default: this.options.allowFullscreen,
|
||||||
|
parseHTML: () => this.options.allowFullscreen,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{
|
||||||
|
tag: 'iframe',
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setIframe: (options: { src: string }) => ({ tr, dispatch }) => {
|
||||||
|
const { selection } = tr
|
||||||
|
const node = this.type.create(options)
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
tr.replaceRangeWith(selection.from, selection.to, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
@ -8,6 +8,7 @@ import ts from "highlight.js/lib/languages/typescript";
|
|||||||
|
|
||||||
import SlashCommand from "./slash-command";
|
import SlashCommand from "./slash-command";
|
||||||
import { UploadImage } from "../";
|
import { UploadImage } from "../";
|
||||||
|
import { EmbedExtension } from "./embed";
|
||||||
|
|
||||||
const lowlight = createLowlight(common)
|
const lowlight = createLowlight(common)
|
||||||
lowlight.register("ts", ts);
|
lowlight.register("ts", ts);
|
||||||
@ -39,6 +40,7 @@ export const RichTextEditorExtensions = (
|
|||||||
class: "mb-6 border-t border-custom-border-300",
|
class: "mb-6 border-t border-custom-border-300",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
EmbedExtension,
|
||||||
SlashCommand(uploadFile, setIsSubmitting),
|
SlashCommand(uploadFile, setIsSubmitting),
|
||||||
CodeBlockLowlight.configure({
|
CodeBlockLowlight.configure({
|
||||||
lowlight,
|
lowlight,
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
useRef,
|
||||||
|
useLayoutEffect,
|
||||||
|
} from "react";
|
||||||
import { Editor, Range, Extension } from "@tiptap/core";
|
import { Editor, Range, Extension } from "@tiptap/core";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
@ -18,7 +25,18 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { UploadImage } from "../";
|
import { UploadImage } from "../";
|
||||||
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
|
import {
|
||||||
|
cn,
|
||||||
|
insertTableCommand,
|
||||||
|
toggleBlockquote,
|
||||||
|
toggleBulletList,
|
||||||
|
toggleOrderedList,
|
||||||
|
toggleTaskList,
|
||||||
|
insertImageCommand,
|
||||||
|
toggleHeadingOne,
|
||||||
|
toggleHeadingTwo,
|
||||||
|
toggleHeadingThree,
|
||||||
|
} from "@plane/editor-core";
|
||||||
|
|
||||||
interface CommandItemProps {
|
interface CommandItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
@ -37,7 +55,15 @@ const Command = Extension.create({
|
|||||||
return {
|
return {
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: "/",
|
char: "/",
|
||||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
command: ({
|
||||||
|
editor,
|
||||||
|
range,
|
||||||
|
props,
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
range: Range;
|
||||||
|
props: any;
|
||||||
|
}) => {
|
||||||
props.command({ editor, range });
|
props.command({ editor, range });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -59,127 +85,153 @@ const Command = Extension.create({
|
|||||||
const getSuggestionItems =
|
const getSuggestionItems =
|
||||||
(
|
(
|
||||||
uploadFile: UploadImage,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
setIsSubmitting?: (
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
|
) => void,
|
||||||
) =>
|
) =>
|
||||||
({ query }: { query: string }) =>
|
({ query }: { query: string }) =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
title: "Text",
|
title: "Text",
|
||||||
description: "Just start typing with plain text.",
|
description: "Just start typing with plain text.",
|
||||||
searchTerms: ["p", "paragraph"],
|
searchTerms: ["p", "paragraph"],
|
||||||
icon: <Text size={18} />,
|
icon: <Text size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
|
editor
|
||||||
},
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.toggleNode("paragraph", "paragraph")
|
||||||
|
.run();
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Heading 1",
|
{
|
||||||
description: "Big section heading.",
|
title: "Heading 1",
|
||||||
searchTerms: ["title", "big", "large"],
|
description: "Big section heading.",
|
||||||
icon: <Heading1 size={18} />,
|
searchTerms: ["title", "big", "large"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <Heading1 size={18} />,
|
||||||
toggleHeadingOne(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleHeadingOne(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Heading 2",
|
{
|
||||||
description: "Medium section heading.",
|
title: "Heading 2",
|
||||||
searchTerms: ["subtitle", "medium"],
|
description: "Medium section heading.",
|
||||||
icon: <Heading2 size={18} />,
|
searchTerms: ["subtitle", "medium"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <Heading2 size={18} />,
|
||||||
toggleHeadingTwo(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleHeadingTwo(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Heading 3",
|
{
|
||||||
description: "Small section heading.",
|
title: "Heading 3",
|
||||||
searchTerms: ["subtitle", "small"],
|
description: "Small section heading.",
|
||||||
icon: <Heading3 size={18} />,
|
searchTerms: ["subtitle", "small"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <Heading3 size={18} />,
|
||||||
toggleHeadingThree(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleHeadingThree(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "To-do List",
|
{
|
||||||
description: "Track tasks with a to-do list.",
|
title: "To-do List",
|
||||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
description: "Track tasks with a to-do list.",
|
||||||
icon: <CheckSquare size={18} />,
|
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <CheckSquare size={18} />,
|
||||||
toggleTaskList(editor, range)
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleTaskList(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Bullet List",
|
{
|
||||||
description: "Create a simple bullet list.",
|
title: "Bullet List",
|
||||||
searchTerms: ["unordered", "point"],
|
description: "Create a simple bullet list.",
|
||||||
icon: <List size={18} />,
|
searchTerms: ["unordered", "point"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <List size={18} />,
|
||||||
toggleBulletList(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleBulletList(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Divider",
|
{
|
||||||
description: "Visually divide blocks",
|
title: "Divider",
|
||||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
description: "Visually divide blocks",
|
||||||
icon: <MinusSquare size={18} />,
|
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <MinusSquare size={18} />,
|
||||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Table",
|
{
|
||||||
description: "Create a Table",
|
title: "Table",
|
||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
description: "Create a Table",
|
||||||
icon: <Table size={18} />,
|
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <Table size={18} />,
|
||||||
insertTableCommand(editor, range);
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
insertTableCommand(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Numbered List",
|
{
|
||||||
description: "Create a list with numbering.",
|
title: "Numbered List",
|
||||||
searchTerms: ["ordered"],
|
description: "Create a list with numbering.",
|
||||||
icon: <ListOrdered size={18} />,
|
searchTerms: ["ordered"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
icon: <ListOrdered size={18} />,
|
||||||
toggleOrderedList(editor, range)
|
command: ({ editor, range }: CommandProps) => {
|
||||||
},
|
toggleOrderedList(editor, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Quote",
|
{
|
||||||
description: "Capture a quote.",
|
title: "Quote",
|
||||||
searchTerms: ["blockquote"],
|
description: "Capture a quote.",
|
||||||
icon: <TextQuote size={18} />,
|
searchTerms: ["blockquote"],
|
||||||
command: ({ editor, range }: CommandProps) =>
|
icon: <TextQuote size={18} />,
|
||||||
toggleBlockquote(editor, range)
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
toggleBlockquote(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Code",
|
||||||
|
description: "Capture a code snippet.",
|
||||||
|
searchTerms: ["codeblock"],
|
||||||
|
icon: <Code size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image",
|
||||||
|
description: "Upload an image from your computer.",
|
||||||
|
searchTerms: ["photo", "picture", "media"],
|
||||||
|
icon: <ImageIcon size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Code",
|
{
|
||||||
description: "Capture a code snippet.",
|
title: "Iframe",
|
||||||
searchTerms: ["codeblock"],
|
description: "Embed an iframe.",
|
||||||
icon: <Code size={18} />,
|
searchTerms: ["youtube", "loom", "embed"],
|
||||||
command: ({ editor, range }: CommandProps) =>
|
icon: <ImageIcon size={18} />,
|
||||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
const url = window.prompt("URL");
|
||||||
|
if (url) {
|
||||||
|
console.log(url);
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setIframe({ src: url })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: "Image",
|
].filter((item) => {
|
||||||
description: "Upload an image from your computer.",
|
if (typeof query === "string" && query.length > 0) {
|
||||||
searchTerms: ["photo", "picture", "media"],
|
const search = query.toLowerCase();
|
||||||
icon: <ImageIcon size={18} />,
|
return (
|
||||||
command: ({ editor, range }: CommandProps) => {
|
item.title.toLowerCase().includes(search) ||
|
||||||
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
item.description.toLowerCase().includes(search) ||
|
||||||
},
|
(item.searchTerms &&
|
||||||
},
|
item.searchTerms.some((term: string) => term.includes(search)))
|
||||||
].filter((item) => {
|
);
|
||||||
if (typeof query === "string" && query.length > 0) {
|
}
|
||||||
const search = query.toLowerCase();
|
return true;
|
||||||
return (
|
});
|
||||||
item.title.toLowerCase().includes(search) ||
|
|
||||||
item.description.toLowerCase().includes(search) ||
|
|
||||||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||||
const containerHeight = container.offsetHeight;
|
const containerHeight = container.offsetHeight;
|
||||||
@ -213,7 +265,7 @@ const CommandList = ({
|
|||||||
command(item);
|
command(item);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[command, items]
|
[command, items],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -266,7 +318,10 @@ const CommandList = ({
|
|||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
||||||
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
|
{
|
||||||
|
"bg-custom-primary-100/5 text-custom-text-100":
|
||||||
|
index === selectedIndex,
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => selectItem(index)}
|
onClick={() => selectItem(index)}
|
||||||
@ -331,7 +386,9 @@ const renderItems = () => {
|
|||||||
|
|
||||||
export const SlashCommand = (
|
export const SlashCommand = (
|
||||||
uploadFile: UploadImage,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
setIsSubmitting?: (
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved",
|
||||||
|
) => void,
|
||||||
) =>
|
) =>
|
||||||
Command.configure({
|
Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
|
@ -7,7 +7,7 @@ import { FileService } from "services/file.service";
|
|||||||
// components
|
// components
|
||||||
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Tooltip } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
import { Globe2, Lock } from "lucide-react";
|
import { Globe2, Lock } from "lucide-react";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
@ -72,35 +72,6 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
|
|||||||
<form onSubmit={handleSubmit(handleAddComment)}>
|
<form onSubmit={handleSubmit(handleAddComment)}>
|
||||||
<div>
|
<div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{showAccessSpecifier && (
|
|
||||||
<div className="absolute bottom-2 left-3 z-[1]">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="access"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
|
|
||||||
{commentAccess.map((access) => (
|
|
||||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(access.key)}
|
|
||||||
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
|
|
||||||
value === access.key ? "bg-custom-background-80" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<access.icon
|
|
||||||
className={`w-4 h-4 -mt-1 ${
|
|
||||||
value === access.key ? "!text-custom-text-100" : "!text-custom-text-400"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Controller
|
<Controller
|
||||||
name="access"
|
name="access"
|
||||||
control={control}
|
control={control}
|
||||||
|
@ -49,6 +49,14 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [localValue, setLocalValue] = useState("");
|
||||||
|
const nameValue = watch("name");
|
||||||
|
useEffect(() => {
|
||||||
|
if (localValue === "" && nameValue !== "") {
|
||||||
|
setLocalValue(nameValue);
|
||||||
|
}
|
||||||
|
}, [nameValue, localValue]);
|
||||||
|
|
||||||
const handleDescriptionFormSubmit = useCallback(
|
const handleDescriptionFormSubmit = useCallback(
|
||||||
async (formData: Partial<IIssue>) => {
|
async (formData: Partial<IIssue>) => {
|
||||||
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
|
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
|
||||||
@ -81,7 +89,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
});
|
});
|
||||||
}, [issue, reset]);
|
}, [issue, reset]);
|
||||||
|
|
||||||
const debouncedTitleSave = useDebouncedCallback(async () => {
|
const debouncedFormSave = useDebouncedCallback(async () => {
|
||||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
@ -92,18 +100,19 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="name"
|
name="name"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<TextArea
|
<TextArea
|
||||||
|
value={localValue}
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
value={value}
|
|
||||||
placeholder="Enter issue name"
|
placeholder="Enter issue name"
|
||||||
onFocus={() => setCharacterLimit(true)}
|
onFocus={() => setCharacterLimit(true)}
|
||||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setCharacterLimit(false);
|
setCharacterLimit(false);
|
||||||
setIsSubmitting("submitting");
|
setIsSubmitting("submitting");
|
||||||
debouncedTitleSave();
|
setLocalValue(e.target.value);
|
||||||
onChange(e.target.value);
|
onChange(e.target.value);
|
||||||
|
debouncedFormSave();
|
||||||
}}
|
}}
|
||||||
required={true}
|
required={true}
|
||||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
|
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
|
||||||
@ -135,7 +144,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
value={value}
|
value={value}
|
||||||
debouncedUpdatesEnabled={true}
|
|
||||||
setShouldShowAlert={setShowAlert}
|
setShouldShowAlert={setShowAlert}
|
||||||
setIsSubmitting={setIsSubmitting}
|
setIsSubmitting={setIsSubmitting}
|
||||||
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
|
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
|
||||||
@ -144,7 +152,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
|||||||
setShowAlert(true);
|
setShowAlert(true);
|
||||||
setIsSubmitting("submitting");
|
setIsSubmitting("submitting");
|
||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
debouncedFormSave();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -229,3 +229,23 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
|||||||
.ProseMirror table * .is-empty::before {
|
.ProseMirror table * .is-empty::before {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* iframe */
|
||||||
|
.iframe-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 56.25%;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.iframe-wrapper.ProseMirror-selectednode {
|
||||||
|
outline: 3px solid #68cef8;
|
||||||
|
}
|
||||||
|
.iframe-wrapper iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user