[PE-273] chore: mobile-editor (#1565)

This commit is contained in:
Lakhan Baheti
2025-03-19 15:44:16 +05:30
committed by GitHub
parent 2e982c4098
commit c86c0fc42a
52 changed files with 3374 additions and 676 deletions

View File

@@ -1,5 +1,4 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types

View File

@@ -15,9 +15,8 @@ const renderUserCursor = (user: { color: string; name: string }): HTMLSpanElemen
cursor.setAttribute("style", `border-color: ${user.color}`);
const label = document.createElement("span");
label.classList.value =
"absolute rounded-[3px_3px_3px_0] text-[#0d0d0d] text-xs font-semibold leading-normal -top-[1.3rem] -left-[1px] py-0.5 px-1.5 select-none whitespace-nowrap";
"custom-collaboration-cursor absolute rounded-[3px_3px_3px_0] text-[#0d0d0d] text-xs font-semibold leading-normal -top-[1.3rem] -left-[1px] py-0.5 px-1.5 select-none whitespace-nowrap";
label.setAttribute("style", `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);

View File

@@ -1,5 +1,5 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { AnyExtension } from "@tiptap/core";
// ui
import { LayersIcon } from "@plane/ui";
// extensions
@@ -20,56 +20,53 @@ type Props = {
userDetails: TUserDetails;
};
export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { disabledExtensions, issueEmbedConfig, provider, userDetails } = props;
type ExtensionConfig = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: Props) => AnyExtension;
};
const isIssueEmbedDisabled = !!disabledExtensions?.includes("issue-embed");
const isCollaborationCursorDisabled = !!disabledExtensions?.includes("collaboration-cursor");
const createSlashCommandOptions: TSlashCommandAdditionalOption[] = [
{
commandKey: "issue-embed",
key: "issue-embed",
title: "Work item embed",
description: "Embed work item from the project.",
searchTerms: ["work item", "link", "embed"],
icon: <LayersIcon className="size-3.5" />,
command: ({ editor, range }) => {
editor.chain().focus().insertContentAt(range, "<p>#workitem_</p>").run();
},
section: "general",
pushAfter: "callout",
},
];
const extensions: Extensions = [];
const additionalSlashCommandOptions: TSlashCommandAdditionalOption[] = [];
if (!isIssueEmbedDisabled) {
const issueEmbedOption: TSlashCommandAdditionalOption = {
commandKey: "issue-embed",
key: "issue-embed",
title: "Work item embed",
description: "Embed work item from the project.",
searchTerms: ["work item", "link", "embed"],
icon: <LayersIcon className="size-3.5" />,
command: ({ editor, range }) => {
editor.chain().focus().insertContentAt(range, "<p>#workitem_</p>").run();
},
section: "general",
pushAfter: "callout",
};
additionalSlashCommandOptions.push(issueEmbedOption);
}
extensions.push(
SlashCommands({
additionalOptions: additionalSlashCommandOptions,
disabledExtensions,
})
);
if (issueEmbedConfig && !isIssueEmbedDisabled) {
extensions.push(
const extensionRegistry: ExtensionConfig[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: () => SlashCommands({ additionalOptions: createSlashCommandOptions }),
},
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("issue-embed"),
getExtension: ({ issueEmbedConfig }) =>
IssueEmbedSuggestions.configure({
suggestion: {
render: () => issueEmbedConfig.searchCallback && IssueListRenderer(issueEmbedConfig.searchCallback),
render: () => issueEmbedConfig?.searchCallback && IssueListRenderer(issueEmbedConfig.searchCallback),
},
})
);
}
}),
},
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("collaboration-cursor"),
getExtension: ({ provider, userDetails }) => CustomCollaborationCursor({ provider, userDetails }),
},
];
if (!isCollaborationCursorDisabled) {
extensions.push(
CustomCollaborationCursor({
provider,
userDetails,
})
);
}
export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { disabledExtensions = [] } = props;
return extensions;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props));
return documentExtensions;
};

View File

@@ -4,7 +4,7 @@ import { TSlashCommandAdditionalOption } from "@/extensions";
import { TExtensions } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
disabledExtensions?: TExtensions[];
};
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {

24
packages/mobile-editor/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,6 @@
.next
.vercel
.tubro
out/
dis/
build/

View File

@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Plane Mobile Editor</title>
</head>
<body>
<div id="root" data-theme="light"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
{
"name": "@plane/mobile-editor",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"vite:build": "tsc -b && vite build",
"build": "turbo vite:build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@plane/editor": "*",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-cookie": "^7.2.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/tailwind-config": "*",
"@plane/types": "*",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@eslint/js": "^9.9.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"typescript-eslint": "^8.0.1",
"globals": "^15.9.0",
"vite": "^5.4.1",
"vite-plugin-node-polyfills": "^0.22.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,214 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.text-1\.5xl {
font-size: 1.375rem;
line-height: 1.875rem;
}
.text-2\.5xl {
font-size: 1.75rem;
line-height: 2.25rem;
}
}
@layer base {
html {
font-family: "Inter", sans-serif;
}
:root {
color-scheme: light !important;
--color-primary-100: 63, 118, 255;
--color-primary-200: 57, 106, 230;
--color-primary-300: 50, 94, 204;
--color-error-10: 255 252 252;
--color-error-20: 255 247 247;
--color-error-30: 255, 219, 220;
--color-error-100: 244 170 170;
--color-error-200: 220 62 62;
--color-error-500: 140 51 58;
--color-background-100: 255, 255, 255; /* primary bg */
--color-background-90: 247, 247, 247; /* secondary bg */
--color-background-80: 237, 238, 243 !important; /* tertiary bg */
--color-background-primary: var(--color-primary-10);
--color-background-error: var(--color-error-20);
--color-text-100: 23, 23, 23; /* primary text */
--color-text-200: 58, 58, 58; /* secondary text */
--color-text-300: 82, 82, 82; /* tertiary text */
--color-text-350: 130, 130, 130;
--color-text-400: 163, 163, 163; /* placeholder text */
--color-text-primary: var(--color-primary-100);
--color-text-error: var(--color-error-200);
--color-scrollbar: 163, 163, 163; /* scrollbar thumb */
--color-border-100: 245, 245, 245; /* subtle border= 1 */
--color-border-200: 229, 229, 229; /* subtle border- 2 */
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
--color-border-primary: var(--color-primary-40);
--color-border-error: var(--color-error-100);
--color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06),
0px 1px 2px 0px rgba(23, 23, 23, 0.14);
--color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12),
0px 1px 8px -1px rgba(16, 24, 40, 0.1);
--color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02),
0px 1px 12px 0px rgba(0, 0, 0, 0.12);
--color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08),
0px 1px 12px 0px rgba(16, 24, 40, 0.04);
--color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12),
0px 1px 16px 0px rgba(16, 24, 40, 0.12);
--color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
0px 1px 24px 0px rgba(16, 24, 40, 0.12);
--color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16),
0px 0px 52px 0px rgba(16, 24, 40, 0.16);
--color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12),
0px 1px 32px 0px rgba(16, 24, 40, 0.12);
--color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
0px 1px 48px 0px rgba(16, 24, 40, 0.12);
--color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05);
}
[data-theme="light"],
[data-theme="light-contrast"] {
color-scheme: light !important;
--color-background-100: 255, 255, 255; /* primary bg */
--color-background-90: 247, 247, 247; /* secondary bg */
--color-background-80: 237, 238, 243 !important; /* tertiary bg */
}
[data-theme="light"] {
--color-text-100: 23, 23, 23; /* primary text */
--color-text-200: 58, 58, 58; /* secondary text */
--color-text-300: 82, 82, 82; /* tertiary text */
--color-text-350: 130, 130, 130;
--color-text-400: 163, 163, 163; /* placeholder text */
--color-scrollbar: 163, 163, 163; /* scrollbar thumb */
--color-border-100: 245, 245, 245; /* subtle border= 1 */
--color-border-200: 229, 229, 229; /* subtle border- 2 */
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
}
[data-theme="light-contrast"] {
--color-text-100: 11, 11, 11; /* primary text */
--color-text-200: 38, 38, 38; /* secondary text */
--color-text-300: 58, 58, 58; /* tertiary text */
--color-text-350: 80, 80, 80;
--color-text-400: 115, 115, 115; /* placeholder text */
--color-scrollbar: 115, 115, 115; /* scrollbar thumb */
--color-border-100: 34, 34, 34; /* subtle border= 1 */
--color-border-200: 38, 38, 38; /* subtle border- 2 */
--color-border-300: 46, 46, 46; /* strong border- 1 */
--color-border-400: 58, 58, 58; /* strong border- 2 */
}
[data-theme="dark"],
[data-theme="dark-contrast"] {
color-scheme: dark !important;
--color-background-100: 25, 25, 25; /* primary bg */
--color-background-90: 32, 32, 32; /* secondary bg */
--color-background-80: 44, 44, 44; /* tertiary bg */
--color-background-primary: var(--color-background-90);
--color-background-error: var(--color-background-90);
--color-text-primary: var(--color-primary-40);
--color-text-error: var(--color-error-100);
--color-border-primary: var(--color-primary-200);
--color-border-error: var(--color-error-500);
--color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5);
--color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5);
--color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), 0px 2px 6px 0px rgba(0, 0, 0, 0.5);
--color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2), 0px 4px 6px 0px rgba(0, 0, 0, 0.5);
--color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2), 0px 4px 8px 0px rgba(0, 0, 0, 0.5);
--color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25), 0px 4px 10px 0px rgba(0, 0, 0, 0.55);
--color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55);
--color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6);
--color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65);
}
[data-theme="dark"] {
--color-text-100: 229, 229, 229; /* primary text */
--color-text-200: 163, 163, 163; /* secondary text */
--color-text-300: 115, 115, 115; /* tertiary text */
--color-text-350: 130, 130, 130;
--color-text-400: 82, 82, 82; /* placeholder text */
--color-scrollbar: 82, 82, 82; /* scrollbar thumb */
--color-border-100: 34, 34, 34; /* subtle border= 1 */
--color-border-200: 38, 38, 38; /* subtle border- 2 */
--color-border-300: 46, 46, 46; /* strong border- 1 */
--color-border-400: 58, 58, 58; /* strong border- 2 */
}
[data-theme="dark-contrast"] {
--color-text-100: 250, 250, 250; /* primary text */
--color-text-200: 241, 241, 241; /* secondary text */
--color-text-300: 212, 212, 212; /* tertiary text */
--color-text-350: 190, 190, 190 --color-text-400: 115, 115, 115; /* placeholder text */
--color-scrollbar: 115, 115, 115; /* scrollbar thumb */
--color-border-100: 245, 245, 245; /* subtle border= 1 */
--color-border-200: 229, 229, 229; /* subtle border- 2 */
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
}
[data-theme="light"],
[data-theme="dark"],
[data-theme="light-contrast"],
[data-theme="dark-contrast"] {
--color-primary-100: 63, 118, 255;
--color-primary-200: 57, 106, 230;
--color-primary-300: 50, 94, 204;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-variant-ligatures: none;
-webkit-font-variant-ligatures: none;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
}
body {
color: rgba(var(--color-text-100));
}
/* scrollbar style */
::-webkit-scrollbar {
display: none;
}

View File

@@ -0,0 +1,15 @@
import { EditorWrapper } from "@/components/editor/editor-wrapper";
import useQueryParams from "@/hooks/use-query-params";
import { MobileDocumentEditor } from "@/components/document-editor/document-editor";
import { TEditorVariant } from "@/types/editor";
export const App = () => {
const { variant } = useQueryParams(["variant"]);
// If the variant is not valid, do not render the editor.
if (!variant || !Object.keys(TEditorVariant).includes(variant)) return null;
if (variant === TEditorVariant.document) return <MobileDocumentEditor />;
return <EditorWrapper variant={variant as TEditorVariant} />;
};

View File

@@ -0,0 +1,150 @@
import {
CollaborativeDocumentEditorWithRef,
EditorRefApi,
TDisplayConfig,
TExtensions,
TRealtimeConfig,
TServerHandler,
} from "@plane/editor";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { callNative, generateRandomColor, getEditorFileHandlers } from "@/helpers";
import { TDocumentEditorParams } from "@/types/editor";
import { useMentions, useMobileEditor, useToolbar, useEditorFlagging, useDisableZoom } from "@/hooks";
import { IssueEmbedCard, IssueEmbedUpgradeCard, PageContentLoader } from "@/components";
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
import { TWebhookConnectionQueryParams } from "@plane/types";
export const MobileDocumentEditor = () => {
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
const [initialParams, setInitialParams] = useState<TDocumentEditorParams | undefined>();
// hooks
const { disabledExtensions, isIssueEmbedEnabled } = useEditorFlagging();
const editorRef = useRef<EditorRefApi>(null);
// It disables zooming in the editor.
useDisableZoom();
// It keeps the native toolbar in sync with the editor state.
const { updateActiveStates } = useToolbar(editorRef);
const { handleEditorReady, onEditorFocus } = useMobileEditor(editorRef);
const { mentionSuggestionsRef, mentionHighlightsRef } = useMentions();
const fileHandler = useMemo(
() =>
getEditorFileHandlers({
workspaceSlug: initialParams?.workspaceSlug ?? "",
workspaceId: initialParams?.workspaceId ?? "",
projectId: initialParams?.projectId ?? "",
baseApi: initialParams?.baseApi ?? "",
}),
[initialParams]
);
const displayConfig: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
};
const handleServerConnect = useCallback(() => setHasConnectionFailed(false), []);
const handleServerError = useCallback(() => setHasConnectionFailed(true), []);
const serverHandler: TServerHandler = useMemo(
() => ({
onConnect: handleServerConnect,
onServerError: handleServerError,
}),
[]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
if (!initialParams) return undefined;
// Construct the WebSocket Collaboration URL
try {
const LIVE_SERVER_BASE_URL = initialParams?.liveServerUrl.trim();
const WS_LIVE_URL = new URL(LIVE_SERVER_BASE_URL);
const isSecureEnvironment = initialParams?.liveServerUrl.startsWith("https");
WS_LIVE_URL.protocol = isSecureEnvironment ? "wss" : "ws";
WS_LIVE_URL.pathname = `${initialParams?.liveServerBasePath}/collaboration`;
// Construct realtime config
return {
url: WS_LIVE_URL.toString(),
queryParams: {
workspaceSlug: initialParams.workspaceSlug.toString(),
documentType: initialParams.documentType.toString() as TWebhookConnectionQueryParams["documentType"],
projectId: initialParams.projectId ?? "",
},
};
} catch (error) {
console.error("Error creating realtime config", error);
return undefined;
}
}, [initialParams]);
// Disabled extensions for the editor.
const resolvedDisabledExtensions: TExtensions[] = useMemo(
() => [...(disabledExtensions ?? []), "slash-commands"],
[disabledExtensions]
);
const mentionHandler = useMemo(
() => ({
suggestions: () => Promise.resolve(mentionSuggestionsRef.current),
highlights: () => Promise.resolve(mentionHighlightsRef.current),
}),
[mentionSuggestionsRef.current, mentionHighlightsRef.current]
);
const userConfig = useMemo(
() => ({
id: initialParams?.userId ?? "",
cookie: initialParams?.cookie,
name: initialParams?.userDisplayName ?? "",
color: generateRandomColor(initialParams?.userId ?? ""),
}),
[initialParams]
);
useEffect(() => {
callNative(CallbackHandlerStrings.getInitialDocumentEditorParams).then((params: TDocumentEditorParams) =>
setInitialParams(params)
);
}, []);
if (hasConnectionFailed) return null;
if (!realtimeConfig || !initialParams || !disabledExtensions) return <PageContentLoader />;
return (
<div onClick={onEditorFocus} className="min-h-screen">
<CollaborativeDocumentEditorWithRef
editable={true}
placeholder={"Write something..."}
onTransaction={updateActiveStates}
id={initialParams?.pageId}
fileHandler={fileHandler}
handleEditorReady={handleEditorReady}
ref={editorRef}
containerClassName="min-h-screen p-0"
displayConfig={displayConfig}
editorClassName="pl-6 min-h-screen pb-32 pt-6"
mentionHandler={mentionHandler as any}
realtimeConfig={realtimeConfig}
serverHandler={serverHandler}
user={userConfig}
disabledExtensions={resolvedDisabledExtensions}
embedHandler={{
issue: {
widgetCallback: ({ issueId, projectId, workspaceSlug }) => {
if (!isIssueEmbedEnabled) return <IssueEmbedUpgradeCard />;
return <IssueEmbedCard issueId={issueId} projectId={projectId} workspaceSlug={workspaceSlug} />;
},
},
}}
/>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./document-editor";
export * from "./page-content-loader";

View File

@@ -0,0 +1,42 @@
"use client";
import { Loader } from "@plane/ui";
export const PageContentLoader = () => (
<div className="size-full">
<Loader className="relative space-y-4">
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
);

View File

@@ -0,0 +1,99 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { EditorRefApi, LiteTextEditorWithRef, RichTextEditorWithRef, TExtensions } from "@plane/editor";
import { useDisableZoom, useToolbar, useMentions, useMobileEditor } from "@/hooks";
import { getEditorFileHandlers } from "@/helpers/editor-file-asset.helper";
import { TEditorParams, TEditorVariant } from "@/types/editor";
import { callNative } from "@/helpers";
import { TrailingNode } from "@/extensions/trailing-node";
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
export const EditorWrapper = ({ variant }: { variant: TEditorVariant }) => {
const editorRef = useRef<EditorRefApi>(null);
const [initialParams, setInitialParams] = useState<TEditorParams | undefined>();
// It is a custom hook that disables zooming in the editor.
useDisableZoom();
// It keeps the native toolbar in sync with the editor state.
const { updateActiveStates } = useToolbar(editorRef);
const { handleEditorReady, onEditorFocus } = useMobileEditor(editorRef);
const { mentionSuggestionsRef, mentionHighlightsRef } = useMentions();
const fileHandler = useMemo(
() =>
getEditorFileHandlers({
workspaceSlug: initialParams?.workspaceSlug ?? "",
workspaceId: initialParams?.workspaceId ?? "",
projectId: initialParams?.projectId ?? "",
baseApi: initialParams?.baseApi ?? "",
}),
[initialParams?.workspaceSlug, initialParams?.workspaceId, initialParams?.projectId, initialParams?.baseApi]
);
// This is called by the native code to reset the initial params of the editor.
const resetInitialParams = useCallback((params: TEditorParams) => {
setInitialParams(params);
}, []);
// This is called when the editor is ready to get the initial params from the native code.
useEffect(() => {
callNative(CallbackHandlerStrings.getInitialEditorParams).then((params: TEditorParams) => setInitialParams(params));
}, []);
// Disabled extensions for the editor.
const disabledExtensions: TExtensions[] = useMemo(() => ["enter-key", "slash-commands"], []);
// Additional extensions for the editor.
const externalExtensions = useMemo(() => [TrailingNode], []);
const mentionHandler = useMemo(
() => ({
suggestions: () => Promise.resolve(mentionSuggestionsRef.current),
highlights: () => Promise.resolve(mentionHighlightsRef.current),
}),
[mentionSuggestionsRef.current, mentionHighlightsRef.current]
);
window.resetInitialParams = resetInitialParams;
if (!initialParams) return null;
return (
<div className="scrollbar-hidden h-screen" onClick={onEditorFocus}>
{variant === TEditorVariant.lite && (
<LiteTextEditorWithRef
ref={editorRef}
autofocus
disabledExtensions={disabledExtensions}
extensions={externalExtensions}
placeholder={initialParams?.placeholder}
onTransaction={updateActiveStates}
handleEditorReady={handleEditorReady}
editorClassName="min-h-screen pb-32"
containerClassName="p-0 border-none"
mentionHandler={mentionHandler as any}
fileHandler={fileHandler}
initialValue={initialParams?.content ?? "<p></p>"}
id="lite-editor"
/>
)}
{variant === TEditorVariant.rich && (
<RichTextEditorWithRef
ref={editorRef}
extensions={externalExtensions}
placeholder={initialParams?.placeholder}
editorClassName="min-h-screen pb-32"
containerClassName="p-0 border-none"
bubbleMenuEnabled={false}
disabledExtensions={disabledExtensions}
mentionHandler={mentionHandler as any}
onTransaction={updateActiveStates}
handleEditorReady={handleEditorReady}
fileHandler={fileHandler}
initialValue={initialParams?.content ?? "<p></p>"}
id="rich-editor"
/>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./editor-wrapper";

View File

@@ -0,0 +1,3 @@
export * from "./document-editor";
export * from "./issue-embed";
export * from "./document-editor";

View File

@@ -0,0 +1,3 @@
export * from "./issue-embed";
export * from "./issue-embed-upgrade";
export * from "./issue-identifier";

View File

@@ -0,0 +1,28 @@
import { Button } from "@plane/ui";
import { Crown } from "lucide-react";
export const IssueEmbedUpgradeCard = () => {
return (
<div
className={
"w-full h-min cursor-pointer space-y-2.5 rounded-lg bg-[#F3F4F7] border-[0.5px] border-custom-border-200 shadow-custom-shadow-2xs"
}
>
<div className="relative h-[71%]">
<div className="h-full backdrop-filter backdrop-blur-[30px] bg-opacity-30 flex items-center w-full justify-between gap-5 pl-4 pr-5 py-3 max-md:max-w-full max-md:flex-wrap relative">
<div className="flex-col items-center">
<div className="rounded p-2 bg-white w-min mb-3">
<Crown size={16} color="#FFBA18" />
</div>
<div className="text-custom-text text-base">
Embed and access issues in pages seamlessly, upgrade to plane pro now.
</div>
</div>
<Button className="py-2" variant="primary" onClick={() => {}}>
Upgrade
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { AlertTriangle } from "lucide-react";
// types
import { IIssueDisplayProperties } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// constants
import { ISSUE_DISPLAY_PROPERTIES } from "@/constants/issue";
// components
import { IssueIdentifier } from "@/components/issue-embed/issue-identifier";
import { TIssue } from "@/types/issue";
import { callNative } from "@/helpers";
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
type Props = {
issueId: string;
projectId?: string;
workspaceSlug?: string;
};
export const IssueEmbedCard: React.FC<Props> = (props) => {
const { issueId, projectId, workspaceSlug } = props;
// states
const [issueDetails, setIssueDetails] = useState<TIssue | undefined>(undefined);
const [error, setError] = useState<any | null>(null);
// issue display properties
const displayProperties: IIssueDisplayProperties = {};
ISSUE_DISPLAY_PROPERTIES.forEach((property) => {
displayProperties[property.key] = true;
});
// get the issue details from the native code.
useEffect(() => {
if (!issueDetails) {
callNative(
CallbackHandlerStrings.getIssueDetails,
JSON.stringify({
issueId,
projectId,
workspaceSlug,
})
).then((issue: string) => setIssueDetails(JSON.parse(issue)));
}
}, [issueDetails, issueId, projectId, workspaceSlug]);
if (!issueDetails && !error)
return (
<div className="rounded-md my-4">
<Loader>
<Loader.Item height="30px" />
<div className="mt-3 space-y-2">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
</div>
);
if (error)
return (
<div className="flex items-center gap-3 rounded-md border-2 border-orange-500 bg-orange-500/10 text-orange-500 py-3 my-2 text-base">
<AlertTriangle className="text-orange-500 size-8" />
This Issue embed is not found in any project. It can no longer be updated or accessed from here.
</div>
);
return (
<div className="issue-embed cursor-pointer space-y-2 rounded-lg border border-custom-border-300 shadow-custom-shadow-2xs p-3 px-4 my-2">
<IssueIdentifier
workspaceSlug={workspaceSlug}
projectId={projectId}
issueIdentifier={issueDetails?.sequenceId?.toString() ?? ""}
/>
<h4 className="!text-lg !font-medium !mt-2 line-clamp-2 break-words">{issueDetails?.name}</h4>
{/*issueDetails && (
<IssueProperties
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
issue={issueDetails}
displayProperties={displayProperties}
/>
)} */}
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { Loader } from "@plane/ui";
import React, { useEffect, useState } from "react";
import { callNative } from "@/helpers/flutter-callback.helper";
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
type Props = {
issueIdentifier: string;
projectId?: string;
workspaceSlug?: string;
};
export const IssueIdentifier: React.FC<Props> = (props) => {
const { projectId, workspaceSlug, issueIdentifier } = props;
const [projectIdentifier, setProjectIdentifier] = useState<String | undefined>(undefined);
// get the project identifier from the native code.
useEffect(() => {
if (!projectIdentifier) {
callNative(
CallbackHandlerStrings.getProjectIdentifier,
JSON.stringify({
projectId,
workspaceSlug,
})
).then((identifier: string) => setProjectIdentifier(identifier));
}
}, [projectId, workspaceSlug]);
if (!projectIdentifier)
return (
<Loader className="flex flex-shrink-0 w-20 h-5">
<Loader.Item height="100%" width="100%" />
</Loader>
);
return (
<span className={"text-sm font-medium text-custom-text-300"}>{`${projectIdentifier}-${issueIdentifier}`}</span>
);
};

View File

@@ -0,0 +1,20 @@
export class CallbackHandlerStrings {
// Utility callbacks
static readonly getActiveToolbarState = "getActiveToolbarState";
static readonly onEditorFocused = "onEditorFocused";
static readonly onEditorReady = "onEditorReady";
static readonly getInitialEditorParams = "getInitialEditorParams";
static readonly getInitialDocumentEditorParams = "getInitialDocumentEditorParams";
// Mention callbacks
static readonly getMembers = "getMembers";
static readonly getUserId = "getUserId";
// Feature flag callbacks
static readonly getFeatureFlags = "getFeatureFlags";
// Asset callbacks
static readonly getResolvedImageUrl = "getResolvedImageUrl";
static readonly deleteImage = "deleteImage";
static readonly restoreImage = "restoreImage";
// Issue embed callbacks
static readonly getProjectIdentifier = "getProjectIdentifier";
static readonly getIssueDetails = "getIssueDetails";
}

View File

@@ -0,0 +1,134 @@
import {
Bold,
CaseSensitive,
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Image,
Italic,
List,
ListOrdered,
ListTodo,
LucideIcon,
Quote,
Strikethrough,
Table,
Underline,
} from "lucide-react";
// editor
import { TEditorCommands, TEditorFontStyle } from "@plane/editor";
// ui
import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
type TEditorTypes = "lite" | "document";
export type ToolbarMenuItem = {
key: TEditorCommands;
name: string;
icon: LucideIcon;
shortcut?: string[];
editors: TEditorTypes[];
};
export const TYPOGRAPHY_ITEMS: ToolbarMenuItem[] = [
{ key: "text", name: "Text", icon: CaseSensitive, editors: ["document"] },
{ key: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] },
{ key: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] },
{ key: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] },
{ key: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] },
{ key: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] },
{ key: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] },
];
const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [
{ key: "bold", name: "Bold", icon: Bold, shortcut: ["Cmd", "B"], editors: ["lite", "document"] },
{ key: "italic", name: "Italic", icon: Italic, shortcut: ["Cmd", "I"], editors: ["lite", "document"] },
{ key: "underline", name: "Underline", icon: Underline, shortcut: ["Cmd", "U"], editors: ["lite", "document"] },
{
key: "strike",
name: "Strikethrough",
icon: Strikethrough,
shortcut: ["Cmd", "Shift", "S"],
editors: ["lite", "document"],
},
];
const LIST_ITEMS: ToolbarMenuItem[] = [
{
key: "bulleted-list",
name: "Bulleted list",
icon: List,
shortcut: ["Cmd", "Shift", "7"],
editors: ["lite", "document"],
},
{
key: "numbered-list",
name: "Numbered list",
icon: ListOrdered,
shortcut: ["Cmd", "Shift", "8"],
editors: ["lite", "document"],
},
{
key: "to-do-list",
name: "To-do list",
icon: ListTodo,
shortcut: ["Cmd", "Shift", "9"],
editors: ["lite", "document"],
},
];
const USER_ACTION_ITEMS: ToolbarMenuItem[] = [
{ key: "quote", name: "Quote", icon: Quote, editors: ["lite", "document"] },
{ key: "code", name: "Code", icon: Code2, editors: ["lite", "document"] },
];
const COMPLEX_ITEMS: ToolbarMenuItem[] = [
{ key: "table", name: "Table", icon: Table, editors: ["document"] },
{ key: "image", name: "Image", icon: Image, editors: ["lite", "document"] },
];
export const TOOLBAR_ITEMS: {
[editorType in TEditorTypes]: {
[key: string]: ToolbarMenuItem[];
};
} = {
lite: {
basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")),
list: LIST_ITEMS.filter((item) => item.editors.includes("lite")),
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")),
},
document: {
basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")),
list: LIST_ITEMS.filter((item) => item.editors.includes("document")),
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")),
},
};
export const EDITOR_FONT_STYLES: {
key: TEditorFontStyle;
label: string;
icon: any;
}[] = [
{
key: "sans-serif",
label: "Sans serif",
icon: SansSerifIcon,
},
{
key: "serif",
label: "Serif",
icon: SerifIcon,
},
{
key: "monospace",
label: "Mono",
icon: MonospaceIcon,
},
];

View File

@@ -0,0 +1,38 @@
export interface IIssueDisplayProperties {
assignee?: boolean;
start_date?: boolean;
due_date?: boolean;
labels?: boolean;
key?: boolean;
priority?: boolean;
state?: boolean;
sub_issue_count?: boolean;
link?: boolean;
attachment_count?: boolean;
estimate?: boolean;
created_on?: boolean;
updated_on?: boolean;
modules?: boolean;
cycle?: boolean;
issue_type?: boolean;
}
export const ISSUE_DISPLAY_PROPERTIES: {
key: keyof IIssueDisplayProperties;
title: string;
}[] = [
{ key: "key", title: "ID" },
{ key: "issue_type", title: "Issue Type" },
{ key: "assignee", title: "Assignee" },
{ key: "start_date", title: "Start date" },
{ key: "due_date", title: "Due date" },
{ key: "labels", title: "Labels" },
{ key: "priority", title: "Priority" },
{ key: "state", title: "State" },
{ key: "sub_issue_count", title: "Sub issue count" },
{ key: "attachment_count", title: "Attachment count" },
{ key: "link", title: "Link" },
{ key: "estimate", title: "Estimate" },
{ key: "modules", title: "Modules" },
{ key: "cycle", title: "Cycle" },
];

View File

@@ -0,0 +1,65 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// @ts-expect-error - TODO: Remove this when the types are fixed
function nodeEqualsType({ types, node }) {
return (Array.isArray(types) && types.includes(node.type)) || node.type === types;
}
export interface TrailingNodeOptions {
node: string;
notAfter: string[];
}
export const TrailingNode = Extension.create<TrailingNodeOptions>({
name: "trailingNode",
addOptions() {
return {
node: "paragraph",
notAfter: ["paragraph"],
};
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name);
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter((node) => this.options.notAfter.includes(node.name));
return [
new Plugin({
key: plugin,
appendTransaction: (_, __, state) => {
const { doc, tr, schema } = state;
const shouldInsertNodeAtEnd = plugin.getState(state);
const endPosition = doc.content.size;
const type = schema.nodes[this.options.node];
if (!shouldInsertNodeAtEnd) {
return;
}
// eslint-disable-next-line consistent-return
return tr.insert(endPosition, type.create());
},
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild;
return !nodeEqualsType({ node: lastNode, types: disabledNodes });
},
apply: (tr, value) => {
if (!tr.docChanged) {
return value;
}
const lastNode = tr.doc.lastChild;
return !nodeEqualsType({ node: lastNode, types: disabledNodes });
},
},
}),
];
},
});

View File

@@ -0,0 +1,3 @@
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";

View File

@@ -0,0 +1,146 @@
import { TFileHandler } from "@plane/editor";
import { checkURLValidity, callNative } from "@/helpers";
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
type TEditorSrcArgs = {
assetId: string;
projectId?: string;
workspaceSlug: string;
baseApi: string;
};
type TURLArgs = {
src: string;
projectId?: string;
workspaceSlug: string;
workspaceId: string;
baseApi: string;
};
type TEditorFileHandlerArgs = {
projectId?: string;
workspaceSlug: string;
workspaceId: string;
baseApi: string;
};
/**
* @description combines the file path with the base URL
* @param {string} path
* @returns {string} final URL with the base URL
*/
export const getFileURL = async (path: string, baseApi: string) => {
if (!path) return undefined;
const isValidURL = path.startsWith("http");
if (isValidURL) return path;
const url = await callNative(CallbackHandlerStrings.getResolvedImageUrl, `${baseApi}${path}`);
return isValidURL ? path : url;
};
/**
* @description generates the file source using assetId
* @param {TEditorSrcArgs} args
*/
export const getEditorAssetSrc = async (args: TEditorSrcArgs) => {
const { assetId, projectId, workspaceSlug } = args;
let url: string | undefined = "";
if (projectId) {
url = await getFileURL(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/`,
args.baseApi
);
} else {
url = await getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/`, args.baseApi);
}
return url;
};
/**
* @description generate the restore file URL
* @param {TURLArgs} args
* @returns {string} restore URL
*/
export const getRestoreURL = (args: TURLArgs) => {
const { baseApi, src, workspaceId, workspaceSlug } = args;
let url: string | undefined;
if (checkURLValidity(src)) {
const assetKey = src.split("/").pop();
return `${baseApi}/api/workspaces/file-assets/${workspaceId}/${assetKey}`;
}
url = `${args.baseApi}/api/assets/v2/workspaces/${workspaceSlug}/restore/${src}/`;
return url;
};
/**
* @description generate the delete file URL
* @param {TURLArgs} args
*/
export const getDeleteURL = (args: TURLArgs) => {
const { baseApi, src, projectId, workspaceId, workspaceSlug } = args;
let url: string | undefined;
if (checkURLValidity(src)) {
const assetKey = src.split("/").pop();
return `${baseApi}/api/workspaces/file-assets/${workspaceId}/${assetKey}`;
}
if (projectId) {
url = `${args.baseApi}/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${src}/`;
} else {
url = `${args.baseApi}/api/assets/v2/workspaces/${workspaceSlug}/${src}/`;
}
return url;
};
/**
* @description this function returns the file handler required by the editor.
* @param {TEditorFileHandlerArgs} args
*/
export const getEditorFileHandlers = (args: TEditorFileHandlerArgs): TFileHandler => {
const { projectId, workspaceSlug, workspaceId, baseApi } = args;
return {
assetsUploadStatus: {},
getAssetSrc: async (path) => {
if (!path) return "";
if (checkURLValidity(path)) {
return path;
} else {
return (
(await getEditorAssetSrc({
assetId: path,
projectId,
workspaceSlug,
baseApi,
})) ?? ""
);
}
},
upload: async (_, file: File) => Promise.resolve(""),
delete: async (src: string) => {
const url = getDeleteURL({
src,
projectId,
workspaceSlug,
workspaceId,
baseApi,
});
await callNative(CallbackHandlerStrings.deleteImage, url);
},
restore: async (src: string) => {
const url = getRestoreURL({
src,
projectId,
workspaceSlug,
workspaceId,
baseApi,
});
await callNative(CallbackHandlerStrings.restoreImage, url);
},
cancel: () => {},
validation: {
maxFileSize: MAX_FILE_SIZE,
},
};
};
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

View File

@@ -0,0 +1,2 @@
export const callNative = async (method: string, args?: string) =>
await window.flutter_inappwebview?.callHandler(method, args);

View File

@@ -0,0 +1,5 @@
export * from "./mentions.helper";
export * from "./flutter-callback.helper";
export * from "./editor-file-asset.helper";
export * from "./string.helper";
export * from "./common.helper";

View File

@@ -0,0 +1,13 @@
export const transformMentionSuggestions = (projectMembers: any): any[] =>
projectMembers.map((member: any) => ({
entity_name: "user_mention",
entity_identifier: `${member?.id}`,
id: `${member?.id}`,
type: "User",
title: `${member?.displayName}`,
subtitle: member?.email ?? "",
avatar: member?.avatarUrl,
redirect_uri: "",
}));
export const transformMentionHighlights = (userId: any) => [userId];

View File

@@ -0,0 +1,31 @@
export const generateRandomColor = (string: string): string => {
if (!string) return "rgb(var(--color-primary-100))";
string = `${string}`;
const uniqueId = string.length.toString() + string; // Unique identifier based on string length
const combinedString = uniqueId + string;
const hash = Array.from(combinedString).reduce((acc, char) => {
const charCode = char.charCodeAt(0);
return (acc << 5) - acc + charCode;
}, 0);
const hue = hash % 360;
const saturation = 70; // Higher saturation for pastel colors
const lightness = 60; // Mid-range lightness for pastel colors
const randomColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
return randomColor;
};
export const checkURLValidity = (url: string): boolean => {
if (!url) return false;
// regex to support complex query parameters and fragments
const urlPattern =
/^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i;
return urlPattern.test(url);
};

View File

@@ -0,0 +1,6 @@
export * from "./use-mentions";
export * from "./use-mobile-editor";
export * from "./use-toolbar";
export * from "./use-disable-zoom";
export * from "./use-editor-flagging";

View File

@@ -0,0 +1,27 @@
import { useEffect } from "react";
export const useDisableZoom = () => {
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "+" || e.key === "-" || e.key === "=")) {
e.preventDefault();
}
};
window.addEventListener("wheel", handleWheel, { passive: false });
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("wheel", handleWheel);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return null;
};

View File

@@ -0,0 +1,36 @@
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
import { callNative } from "@/helpers/flutter-callback.helper";
import { TFeatureFlagsResponse } from "@/types/feature-flag";
import { TExtensions } from "@plane/editor";
import { useEffect, useState } from "react";
/**
* @description extensions disabled in various editors
*/
export const useEditorFlagging = (): {
disabledExtensions?: TExtensions[];
isIssueEmbedEnabled: boolean;
} => {
const [featureFlags, setFeatureFlags] = useState<TFeatureFlagsResponse | null>(null);
const [disabledExtensions, setDocumentEditor] = useState<TExtensions[] | undefined>(undefined);
// get the feature flags from the native code
useEffect(() => {
callNative(CallbackHandlerStrings.getFeatureFlags).then((flags: string) => {
console.log(flags);
setFeatureFlags(JSON.parse(flags));
setDocumentEditor([]);
});
}, []);
const isIssueEmbedEnabled = featureFlags?.pageIssueEmbeds ?? false;
const isCollaborationCursorEnabled = featureFlags?.collaborationCursor ?? false;
// extensions disabled in the document editor
if (!isIssueEmbedEnabled) disabledExtensions?.push("issue-embed");
if (!isCollaborationCursorEnabled) disabledExtensions?.push("collaboration-cursor");
return {
disabledExtensions,
isIssueEmbedEnabled,
};
};

View File

@@ -0,0 +1,48 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { transformMentionSuggestions } from "@/helpers/mentions.helper";
import { callNative } from "@/helpers/flutter-callback.helper";
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
export const useMentions = () => {
// Mention suggestions and highlights are stored in refs, so they can be updated without re-initializing the editor.
const mentionSuggestionsRef = useRef<any[]>([]);
const mentionHighlightsRef = useRef<string[]>([]);
// Get the members from the native code.
const getMembers = useCallback(async (): Promise<any[]> => {
await callNative(CallbackHandlerStrings.getMembers).then((members: any) => {
const mentionSuggestions = transformMentionSuggestions(members);
mentionSuggestionsRef.current = mentionSuggestions;
return mentionSuggestions;
});
return [];
}, []);
// Set the members in the mention
const setMembers = useCallback((members: any) => {
const mentionSuggestions = transformMentionSuggestions(members);
mentionSuggestionsRef.current = mentionSuggestions;
}, []);
// Get the userId from the native
const getUserId = useCallback(
() =>
callNative(CallbackHandlerStrings.getUserId).then((userId: string) => (mentionHighlightsRef.current = [userId])),
[]
);
window.getMembers = getMembers;
window.setMembers = setMembers;
window.getUserId = getUserId;
useEffect(() => {
getMembers();
getUserId();
}, []);
return {
mentionSuggestionsRef,
mentionHighlightsRef,
};
};

View File

@@ -0,0 +1,59 @@
import React, { useCallback } from "react";
import { EditorRefApi, TEditorCommands } from "@plane/editor";
import { callNative } from "@/helpers/flutter-callback.helper";
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
export const useMobileEditor = (editorRef: React.MutableRefObject<EditorRefApi | null>) => {
/**
* @returns the current content of the editor in HTML format.
*/
const sendHtmlContent = useCallback(() => editorRef.current?.getDocument().html, [editorRef.current]);
// Notifies the native code that the editor is focused.
const onEditorFocus = useCallback(() => callNative(CallbackHandlerStrings.onEditorFocused), []);
// Executes the action based on the action key.
const executeAction = useCallback(
(actionKey: TEditorCommands) => {
// @ts-expect-error type mismatch here
editorRef.current?.executeMenuItemCommand({
itemKey: actionKey,
});
editorRef.current?.scrollToNodeViaDOMCoordinates("instant");
},
[editorRef.current]
);
/**
* @description Unfocus the editor.
* @usecase This is required to remove the focus from the editor when the user taps outside the editor.
*/
const unfocus = useCallback(() => editorRef.current?.blur(), [editorRef.current]);
// Scrolls to the focused node in the editor.
const scrollToFocus = useCallback(
() => editorRef.current?.scrollToNodeViaDOMCoordinates("instant"),
[editorRef.current]
);
/**
* @description when the editor is ready, call the native code to notify that the editor is ready.
* @param isReady - boolean
*/
const handleEditorReady = useCallback((isReady: boolean) => {
if (isReady) {
callNative(CallbackHandlerStrings.onEditorReady);
}
}, []);
// Expose the functions to the window object.
window.unfocus = unfocus;
window.scrollToFocus = scrollToFocus;
window.executeAction = executeAction;
window.sendHtmlContent = sendHtmlContent;
return {
handleEditorReady,
onEditorFocus,
};
};

View File

@@ -0,0 +1,23 @@
import { useMemo } from "react";
type QueryParams<T extends string> = {
[key in T]: string;
};
export function useQueryParams<T extends string>(
paramNames: Array<T>,
): QueryParams<T> {
return useMemo(() => {
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const params = {} as QueryParams<T>;
paramNames.forEach((name) => {
params[name] = urlParams.get(name) ?? "";
});
return params;
}, [paramNames]);
}
export default useQueryParams;

View File

@@ -0,0 +1,29 @@
"use client";
import { useCallback } from "react";
import { TOOLBAR_ITEMS } from "@/constants/editor";
import { EditorRefApi } from "@plane/editor";
import { callNative } from "@/helpers/flutter-callback.helper";
import { CallbackHandlerStrings } from "@/constants/callback-handler-strings";
export const useToolbar = (editorRef: React.MutableRefObject<EditorRefApi | null>) => {
// Notifies the native code to with active toolbar state.
const updateActiveStates = useCallback(() => {
if (!editorRef.current) return;
const newActiveStates: Record<string, boolean> = {};
Object.values(TOOLBAR_ITEMS.document)
.flat()
.forEach((item) => {
newActiveStates[item.key] =
// @ts-expect-error type mismatch here
editorRef.current?.isMenuItemActive({
itemKey: item.key,
}) ?? false;
});
callNative(CallbackHandlerStrings.getActiveToolbarState, JSON.stringify(newActiveStates));
}, []);
return {
updateActiveStates,
};
};

View File

@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import "./App.css";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,26 @@
type TBaseEditorParams = {
workspaceSlug: string;
workspaceId: string;
placeholder?: string;
baseApi: string;
variant: TEditorVariant;
};
export type TEditorParams = TBaseEditorParams & {
content: string;
projectId?: string;
};
export type TDocumentEditorParams = TBaseEditorParams & {
pageId: string;
documentType: string;
projectId?: string;
userId: string;
userDisplayName: string;
cookie: string;
liveServerUrl: string;
liveServerBasePath: string;
};
export enum TEditorVariant {
lite = "lite",
rich = "rich",
document = "document",
}

View File

@@ -0,0 +1,9 @@
export enum E_FEATURE_FLAGS {
COLLABORATION_CURSOR = "collaborationCursor",
EDITOR_AI_OPS = "editorAIOps",
PAGE_ISSUE_EMBEDS = "pageIssueEmbeds",
}
export type TFeatureFlagsResponse = {
[featureFlag in E_FEATURE_FLAGS]: boolean;
};

View File

@@ -0,0 +1,20 @@
import { TEditorCommands } from "@plane/editor";
import { TEditorParams } from "@/types/editor";
declare global {
interface Window {
flutter_inappwebview: {
callHandler(method: string, args?: string): Promise<any>;
};
// use-mobile-editor.ts
resetInitialParams: (params: TEditorParams) => void;
sendHtmlContent: () => string | undefined;
executeAction: (actionKey: TEditorCommands) => void;
unfocus: () => void;
scrollToFocus: () => void;
// use-mentions.ts
getMembers: () => Promise<any[]>;
setMembers: (members: any) => void;
getUserId: () => void;
}
}

View File

@@ -0,0 +1,36 @@
import { TIssuePriorities } from "@plane/types";
export type TIssue = {
id: string;
sequenceId: number;
name: string;
sort_order: number;
state_id: string | null;
priority: TIssuePriorities | null;
label_ids: string[];
assignee_ids: string[];
estimate_point: string | null;
sub_issues_count: number;
attachment_count: number;
link_count: number;
project_id: string | null;
parent_id: string | null;
cycle_id: string | null;
module_ids: string[] | null;
type_id: string | null;
created_at: string;
updated_at: string;
start_date: string | null;
target_date: string | null;
completed_at: string | null;
archived_at: string | null;
created_by: string;
updated_by: string;
is_draft: boolean;
};

View File

@@ -0,0 +1,299 @@
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
const convertToRGBA = (variableName, alpha) => `rgba(var(${variableName}), ${alpha})`;
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: {
relative: true,
files: [
"./app/**/*.{js,ts,jsx,tsx}",
"./core/**/*.{js,ts,jsx,tsx}",
"./ce/**/*.{js,ts,jsx,tsx}",
"./ee/**/*.{js,ts,jsx,tsx}",
"./components/**/*.tsx",
"./constants/**/*.{js,ts,jsx,tsx}",
"./layouts/**/*.tsx",
"./pages/**/*.tsx",
"./app/**/*.tsx",
"./ui/**/*.tsx",
"./src/**/*.tsx",
"./**/*.tsx",
"../packages/mobile-editor/**/*.{js,ts,jsx,tsx}",
"../packages/ui/**/*.{js,ts,jsx,tsx}",
"../packages/editor/**/src/**/*.{js,ts,jsx,tsx}",
"!../packages/ui/**/*.stories{js,ts,jsx,tsx}",
],
},
theme: {
extend: {
boxShadow: {
"custom-shadow-2xs": "var(--color-shadow-2xs)",
"custom-shadow-xs": "var(--color-shadow-xs)",
"custom-shadow-sm": "var(--color-shadow-sm)",
"custom-shadow-rg": "var(--color-shadow-rg)",
"custom-shadow-md": "var(--color-shadow-md)",
"custom-shadow-lg": "var(--color-shadow-lg)",
"custom-shadow-xl": "var(--color-shadow-xl)",
"custom-shadow-2xl": "var(--color-shadow-2xl)",
"custom-shadow-3xl": "var(--color-shadow-3xl)",
"custom-shadow-4xl": "var(--color-shadow-4xl)",
},
colors: {
custom: {
primary: {
100: convertToRGB("--color-primary-100"),
200: convertToRGB("--color-primary-200"),
300: convertToRGB("--color-primary-300"),
DEFAULT: convertToRGB("--color-primary-100"),
},
background: {
100: convertToRGB("--color-background-100"),
200: convertToRGB("--color-background-200"),
300: convertToRGB("--color-background-300"),
400: convertToRGB("--color-background-400"),
overlay: convertToRGBA("--color-background-80", 0.95),
primary: convertToRGB(" --color-background-primary"),
error: convertToRGB(" --color-background-error"),
DEFAULT: convertToRGB("--color-background-100"),
},
text: {
100: convertToRGB("--color-text-100"),
200: convertToRGB("--color-text-200"),
300: convertToRGB("--color-text-300"),
350: convertToRGB("--color-text-350"),
400: convertToRGB("--color-text-400"),
primary: convertToRGB("--color-text-primary"),
error: convertToRGB("--color-text-error"),
DEFAULT: convertToRGB("--color-text-100"),
},
border: {
0: "rgb(255, 255, 255)",
100: convertToRGB("--color-border-100"),
200: convertToRGB("--color-border-200"),
300: convertToRGB("--color-border-300"),
400: convertToRGB("--color-border-400"),
1000: "rgb(0, 0, 0)",
primary: convertToRGB("--color-border-primary"),
error: convertToRGB("--color-border-error"),
DEFAULT: convertToRGB("--color-border-200"),
},
error: {
10: convertToRGB("--color-error-10"),
20: convertToRGB("--color-error-20"),
30: convertToRGB("--color-error-30"),
100: convertToRGB("--color-error-100"),
200: convertToRGB("--color-error-200"),
500: convertToRGB("--color-error-500"),
},
backdrop: "rgba(0, 0, 0, 0.25)",
},
},
keyframes: {
leftToaster: {
"0%": { left: "-20rem" },
"100%": { left: "0" },
},
rightToaster: {
"0%": { right: "-20rem" },
"100%": { right: "0" },
},
"bar-loader": {
from: { left: "-100%" },
to: { left: "100%" },
},
},
typography: () => ({
brand: {
css: {
"--tw-prose-body": convertToRGB("--color-text-100"),
"--tw-prose-p": convertToRGB("--color-text-100"),
"--tw-prose-headings": convertToRGB("--color-text-100"),
"--tw-prose-lead": convertToRGB("--color-text-100"),
"--tw-prose-links": convertToRGB("--color-primary-100"),
"--tw-prose-bold": "inherit",
"--tw-prose-counters": convertToRGB("--color-text-100"),
"--tw-prose-bullets": convertToRGB("--color-text-100"),
"--tw-prose-hr": convertToRGB("--color-text-100"),
"--tw-prose-quotes": convertToRGB("--color-text-100"),
"--tw-prose-quote-borders": convertToRGB("--color-border-200"),
"--tw-prose-code": convertToRGB("--color-text-100"),
"--tw-prose-pre-code": convertToRGB("--color-text-100"),
"--tw-prose-pre-bg": convertToRGB("--color-background-100"),
"--tw-prose-th-borders": convertToRGB("--color-border-200"),
"--tw-prose-td-borders": convertToRGB("--color-border-200"),
},
},
}),
screens: {
"3xl": "1792px",
},
// scale down font sizes to 90% of default
fontSize: {
xs: "0.675rem",
sm: "0.7875rem",
base: "0.9rem",
lg: "1.0125rem",
xl: "1.125rem",
"2xl": "1.35rem",
"3xl": "1.6875rem",
"4xl": "2.25rem",
"5xl": "2.7rem",
"6xl": "3.375rem",
"7xl": "4.05rem",
"8xl": "5.4rem",
"9xl": "7.2rem",
},
// scale down spacing to 90% of default
padding: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
"page-x": "1.35rem",
"page-y": "1.35rem",
},
margin: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
space: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
gap: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
},
fontFamily: {
custom: ["Inter", "sans-serif"],
},
},
plugins: [
// require("tailwindcss-animate"),
// require("@tailwindcss/typography"),
function ({ addUtilities }) {
const newUtilities = {
// Mobile screens
".px-page-x": {
paddingLeft: "0.675rem",
paddingRight: "0.675rem",
},
// Medium screens (768px and up)
"@media (min-width: 768px)": {
".px-page-x": {
paddingLeft: "1.35rem",
paddingRight: "1.35rem",
},
},
};
addUtilities(newUtilities, ["responsive"]);
},
],
};

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"baseUrl": ".",
"allowJs": false,
"skipLibCheck": true,
"module": "preserve",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"jsx": "react-jsx",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"types"
]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/index.ts","./src/components/document-editor/document-editor.tsx","./src/components/document-editor/index.ts","./src/components/document-editor/page-content-loader.tsx","./src/components/editor/editor-wrapper.tsx","./src/components/editor/index.ts","./src/components/issue-embed/index.ts","./src/components/issue-embed/issue-embed-upgrade.tsx","./src/components/issue-embed/issue-embed.tsx","./src/components/issue-embed/issue-identifier.tsx","./src/constants/callback-handler-strings.ts","./src/constants/editor.ts","./src/constants/issue.ts","./src/extensions/trailing-node.ts","./src/helpers/common.helper.ts","./src/helpers/editor-file-asset.helper.ts","./src/helpers/flutter-callback.helper.ts","./src/helpers/index.ts","./src/helpers/mentions.helper.ts","./src/helpers/string.helper.ts","./src/hooks/index.ts","./src/hooks/use-disable-zoom.ts","./src/hooks/use-editor-flagging.ts","./src/hooks/use-mentions.ts","./src/hooks/use-mobile-editor.ts","./src/hooks/use-query-params.tsx","./src/hooks/use-toolbar.ts","./src/types/editor.ts","./src/types/feature-flag.ts","./src/types/global_functions.ts","./src/types/issue.ts"],"version":"5.7.3"}

View File

@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "tailwindcss";
import path from "path";
export default ({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return defineConfig({
define: {
"process.env": env,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "out",
},
plugins: [react()],
css: {
postcss: {
plugins: [tailwindcss()],
},
},
});
};

View File

@@ -34,6 +34,10 @@
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"vite:build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"develop": {
"cache": false,
"persistent": true,

2001
yarn.lock

File diff suppressed because it is too large Load Diff