diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index eafe1f673..0e74bd728 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -6,6 +6,7 @@ import { HelmetProvider } from "react-helmet-async"; import { RelayEnvironmentProvider } from "react-relay"; import { BrowserRouter, Route, Routes, useLocation } from "react-router"; import "./App.css"; +import "./styles/policy-content.css"; import ErrorBoundary from "./components/ErrorBoundary"; import ConsoleLayout from "./layouts/ConsoleLayout"; import AuthLayout from "./layouts/AuthLayout"; diff --git a/apps/console/src/components/PolicyEditor.css b/apps/console/src/components/PolicyEditor.css new file mode 100644 index 000000000..14d5d4c17 --- /dev/null +++ b/apps/console/src/components/PolicyEditor.css @@ -0,0 +1,230 @@ +.policy-editor-container { + border-radius: 0.5rem; + position: relative; + line-height: 1.5; + font-weight: 400; + margin-top: 0.5rem; + border: 1px solid hsl(var(--border)); + display: block; + width: 100%; +} + +.policy-editor { + background: #fff; + position: relative; + border-radius: 0.5rem; + display: flex; + flex-direction: column; + width: 100%; +} + +.policy-editor-inner { + background: #fff; + position: relative; + border-radius: 0 0 0.5rem 0.5rem; + flex: 1; + min-height: 250px; +} + +.policy-editor-input { + min-height: 250px; + max-height: 500px; + resize: none; + font-size: 15px; + position: relative; + tab-size: 1; + outline: 0; + padding: 15px 10px; + overflow-y: auto; + width: 100%; + display: block; + color: #000; +} + +.policy-editor-placeholder { + color: #999; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 15px; + left: 10px; + font-size: 15px; + user-select: none; + display: inline-block; + pointer-events: none; +} + +.policy-editor-paragraph { + margin: 0 0 15px 0; + position: relative; +} + +.policy-editor .toolbar { + display: flex; + flex-wrap: wrap; + padding: 8px; + border-bottom: 1px solid hsl(var(--border)); + background-color: hsl(var(--muted)); + border-radius: 0.5rem 0.5rem 0 0; +} + +.policy-editor .toolbar button { + border: 0; + display: flex; + background: none; + border-radius: 4px; + padding: 8px; + cursor: pointer; + vertical-align: middle; + align-items: center; + justify-content: center; +} + +.policy-editor .toolbar button:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.policy-editor .toolbar button.active { + background-color: rgba(0, 0, 0, 0.1); +} + +.policy-editor .toolbar .divider { + width: 1px; + background-color: hsl(var(--border)); + margin: 0 8px; + height: 24px; +} + +/* Typography */ +.editor-heading-h1 { + font-size: 24px; + font-weight: 700; + margin: 0 0 12px 0; +} + +.editor-heading-h2 { + font-size: 20px; + font-weight: 700; + margin: 10px 0; +} + +.editor-heading-h3 { + font-size: 18px; + font-weight: 600; + margin: 8px 0; +} + +.editor-heading-h4 { + font-size: 16px; + font-weight: 600; + margin: 8px 0; +} + +.editor-heading-h5 { + font-size: 14px; + font-weight: 600; + margin: 8px 0; +} + +.editor-quote { + margin: 0; + margin-left: 20px; + padding-left: 16px; + border-left: 4px solid hsl(var(--border)); + color: hsl(var(--muted-foreground)); +} + +.editor-list-ol { + padding: 0; + margin: 0 0 0 16px; +} + +.editor-list-ul { + padding: 0; + margin: 0 0 0 16px; +} + +.editor-listitem { + margin: 8px 0 0 0; +} + +.editor-nested-listitem { + list-style-type: none; +} + +/* Text formatting */ +.editor-text-bold { + font-weight: bold; +} + +.editor-text-italic { + font-style: italic; +} + +.editor-text-underline { + text-decoration: underline; +} + +.editor-text-strikethrough { + text-decoration: line-through; +} + +.editor-text-underlineStrikethrough { + text-decoration: underline line-through; +} + +.editor-text-code { + background-color: hsl(var(--muted)); + padding: 1px 4px; + border-radius: 4px; + font-family: monospace; +} + +.editor-link { + color: hsl(var(--primary)); + text-decoration: underline; +} + +.editor-code { + background-color: hsl(var(--muted)); + font-family: monospace; + display: block; + padding: 8px 16px; + line-height: 1.6; + font-size: 13px; + border-radius: 0.5rem; + margin: 8px 0; +} + +/* Code highlight */ +.editor-tokenComment { + color: slategray; +} + +.editor-tokenPunctuation { + color: #999; +} + +.editor-tokenProperty { + color: #905; +} + +.editor-tokenSelector { + color: #690; +} + +.editor-tokenOperator { + color: #9a6e3a; +} + +.editor-tokenAttr { + color: #07a; +} + +.editor-tokenVariable { + color: #e90; +} + +.editor-tokenFunction { + color: #dd4a68; +} \ No newline at end of file diff --git a/apps/console/src/components/PolicyEditor.tsx b/apps/console/src/components/PolicyEditor.tsx new file mode 100644 index 000000000..958d767d8 --- /dev/null +++ b/apps/console/src/components/PolicyEditor.tsx @@ -0,0 +1,381 @@ +import { useEffect, useState } from "react"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS } from "@lexical/markdown"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"; +import { $getRoot, $insertNodes, EditorState, LexicalEditor } from "lexical"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { + Bold, + Italic, + Underline, + List, + ListOrdered, + Heading1, + Heading2, +} from "lucide-react"; +import { $isRangeSelection, FORMAT_TEXT_COMMAND, $getSelection } from "lexical"; +import { $createHeadingNode, $isHeadingNode } from "@lexical/rich-text"; +import { $setBlocksType } from "@lexical/selection"; +import { $createParagraphNode } from "lexical"; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, +} from "@lexical/list"; + +import "./PolicyEditor.css"; + +// Theme for the editor +const theme = { + ltr: "ltr", + rtl: "rtl", + paragraph: "editor-paragraph", + quote: "editor-quote", + heading: { + h1: "editor-heading-h1", + h2: "editor-heading-h2", + h3: "editor-heading-h3", + h4: "editor-heading-h4", + h5: "editor-heading-h5", + }, + list: { + nested: { + listitem: "editor-nested-listitem", + }, + ol: "editor-list-ol", + ul: "editor-list-ul", + listitem: "editor-listitem", + }, + image: "editor-image", + link: "editor-link", + text: { + bold: "editor-text-bold", + italic: "editor-text-italic", + underline: "editor-text-underline", + strikethrough: "editor-text-strikethrough", + underlineStrikethrough: "editor-text-underlineStrikethrough", + code: "editor-text-code", + }, + code: "editor-code", + codeHighlight: { + atrule: "editor-tokenAttr", + attr: "editor-tokenAttr", + boolean: "editor-tokenProperty", + builtin: "editor-tokenSelector", + cdata: "editor-tokenComment", + char: "editor-tokenSelector", + class: "editor-tokenFunction", + "class-name": "editor-tokenFunction", + comment: "editor-tokenComment", + constant: "editor-tokenProperty", + deleted: "editor-tokenProperty", + doctype: "editor-tokenComment", + entity: "editor-tokenOperator", + function: "editor-tokenFunction", + important: "editor-tokenVariable", + inserted: "editor-tokenSelector", + keyword: "editor-tokenAttr", + namespace: "editor-tokenVariable", + number: "editor-tokenProperty", + operator: "editor-tokenOperator", + prolog: "editor-tokenComment", + property: "editor-tokenProperty", + punctuation: "editor-tokenPunctuation", + regex: "editor-tokenVariable", + selector: "editor-tokenSelector", + string: "editor-tokenSelector", + symbol: "editor-tokenProperty", + tag: "editor-tokenProperty", + url: "editor-tokenOperator", + variable: "editor-tokenVariable", + }, +}; + +// Simple Toolbar Plugin +function ToolbarPlugin() { + const [editor] = useLexicalComposerContext(); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [blockType, setBlockType] = useState("paragraph"); + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + setIsBold(selection.hasFormat("bold")); + setIsItalic(selection.hasFormat("italic")); + setIsUnderline(selection.hasFormat("underline")); + + const anchorNode = selection.anchor.getNode(); + const element = anchorNode.getTopLevelElementOrThrow(); + if ($isHeadingNode(element)) { + setBlockType(element.getTag()); + } else { + setBlockType("paragraph"); + } + } + }); + }); + }, [editor]); + + const formatHeading = (headingSize: "h1" | "h2") => { + if (blockType !== headingSize) { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(headingSize)); + } + }); + } else { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + }); + } + }; + + const formatBulletList = () => { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }; + + const formatNumberedList = () => { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + }; + + return ( +
+ + + + +
+ + + + +
+ + + +
+ ); +} + +// Import HTML Plugin +function HtmlImportPlugin({ + initialHtml, + setEditor, +}: { + initialHtml: string; + setEditor: (editor: LexicalEditor) => void; +}) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + console.log( + "HtmlImportPlugin effect running, editor:", + !!editor, + "initialHtml:", + initialHtml + ? initialHtml.substring(0, 50) + (initialHtml.length > 50 ? "..." : "") + : "empty" + ); + + if (editor) { + setEditor(editor); + + editor.update(() => { + try { + if (initialHtml && initialHtml.trim() !== "") { + console.log("Importing HTML content into editor"); + const parser = new DOMParser(); + const dom = parser.parseFromString(initialHtml, "text/html"); + const nodes = $generateNodesFromDOM(editor, dom); + + const root = $getRoot(); + root.clear(); + $insertNodes(nodes); + console.log("HTML content imported successfully"); + } else { + // If no initial content, ensure we have at least a paragraph + const root = $getRoot(); + if (root.getChildrenSize() === 0) { + const paragraph = $createParagraphNode(); + root.append(paragraph); + } + } + } catch (error) { + console.error("Error importing HTML content:", error); + // Ensure we have at least a paragraph on error + const root = $getRoot(); + if (root.getChildrenSize() === 0) { + const paragraph = $createParagraphNode(); + root.append(paragraph); + } + } + }); + } + }, [editor, initialHtml, setEditor]); + + return null; +} + +export interface PolicyEditorProps { + initialContent: string; + onChange: (html: string) => void; +} + +export default function PolicyEditor({ + initialContent, + onChange, +}: PolicyEditorProps) { + const [editor, setEditor] = useState(null); + + console.log("PolicyEditor rendering with initialContent:", initialContent); + + // Function to handle editor changes + const handleEditorChange = (editorState: EditorState) => { + if (!editor) return; + + editorState.read(() => { + const htmlString = $generateHtmlFromNodes(editor); + console.log( + "Editor content changed:", + htmlString.substring(0, 100) + (htmlString.length > 100 ? "..." : "") + ); + onChange(htmlString); + }); + }; + + // Lexical Editor Configuration + const initialConfig = { + namespace: "PolicyEditor", + theme, + onError: (error: Error) => { + console.error("Lexical Editor Error:", error); + }, + nodes: [ + HeadingNode, + QuoteNode, + ListNode, + ListItemNode, + CodeNode, + CodeHighlightNode, + TableNode, + TableCellNode, + TableRowNode, + AutoLinkNode, + LinkNode, + ], + }; + + return ( +
+ +
+ +
+ + } + placeholder={ +
+ Enter policy content... +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + +
+
+
+
+ ); +} diff --git a/apps/console/src/components/editor/ToolbarPlugin.tsx b/apps/console/src/components/editor/ToolbarPlugin.tsx new file mode 100644 index 000000000..28a2990a6 --- /dev/null +++ b/apps/console/src/components/editor/ToolbarPlugin.tsx @@ -0,0 +1,412 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + FORMAT_TEXT_COMMAND, + FORMAT_ELEMENT_COMMAND, + UNDO_COMMAND, + REDO_COMMAND, +} from "lexical"; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, +} from "@lexical/rich-text"; +import { + $createListItemNode, + $createListNode, + $isListNode, + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + ListNode, + REMOVE_LIST_COMMAND, +} from "@lexical/list"; +import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"; +import { + $getSelectionStyleValueForProperty, + $isParentElementRTL, + $patchStyleText, + $setBlocksType, +} from "@lexical/selection"; +import { $getNearestNodeOfType } from "@lexical/utils"; +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { + Bold, + Italic, + Underline, + Strikethrough, + List, + ListOrdered, + Link, + Quote, + Heading1, + Heading2, + Heading3, + Undo, + Redo, + AlignLeft, + AlignCenter, + AlignRight, + AlignJustify, +} from "lucide-react"; + +export function ToolbarPlugin() { + const [editor] = useLexicalComposerContext(); + const [activeEditor, setActiveEditor] = useState(editor); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isLink, setIsLink] = useState(false); + const [blockType, setBlockType] = useState("paragraph"); + const [selectedElementKey, setSelectedElementKey] = useState( + null + ); + const [linkUrl, setLinkUrl] = useState(""); + const [isLinkEditMode, setIsLinkEditMode] = useState(false); + const [textAlignment, setTextAlignment] = useState< + "left" | "center" | "right" | "justify" + >("left"); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + setIsBold(selection.hasFormat("bold")); + setIsItalic(selection.hasFormat("italic")); + setIsUnderline(selection.hasFormat("underline")); + setIsStrikethrough(selection.hasFormat("strikethrough")); + + // Check for links + const node = getSelectedNode(selection); + const parent = node.getParent(); + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true); + } else { + setIsLink(false); + } + + // Get text alignment + const alignment = $getSelectionStyleValueForProperty( + selection, + "text-align", + "left" + ) as "left" | "center" | "right" | "justify"; + setTextAlignment(alignment); + + // Update block type + const anchorNode = selection.anchor.getNode(); + const element = + anchorNode.getKey() === "root" + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + const elementKey = element.getKey(); + const elementDOM = activeEditor.getElementByKey(elementKey); + + setSelectedElementKey(elementKey); + + if (elementDOM !== null) { + if ($isHeadingNode(element)) { + const tag = element.getTag(); + setBlockType(tag); + } else if ($isListNode(element)) { + const parentList = $getNearestNodeOfType( + anchorNode, + ListNode + ); + const listType = parentList + ? parentList.getListType() + : element.getListType(); + setBlockType(listType); + } else { + setBlockType("paragraph"); + } + } + } + }, [activeEditor]); + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateToolbar(); + }); + }); + }, [editor, updateToolbar]); + + const formatHeading = (headingSize: "h1" | "h2" | "h3") => { + if (blockType !== headingSize) { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(headingSize)); + } + }); + } else { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + }); + } + }; + + const formatQuote = () => { + if (blockType !== "quote") { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createQuoteNode()); + } + }); + } else { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + }); + } + }; + + const formatBulletList = () => { + if (blockType !== "ul") { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + } else { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + } + }; + + const formatNumberedList = () => { + if (blockType !== "ol") { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + } else { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + } + }; + + const formatLink = () => { + if (!isLink) { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const url = prompt("Enter URL", "https://"); + if (url) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); + } + } + } else { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + }; + + const formatTextAlignment = ( + alignment: "left" | "center" | "right" | "justify" + ) => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $patchStyleText(selection, { + "text-align": alignment, + }); + } + }); + }; + + return ( +
+ + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ ); +} + +function getSelectedNode(selection: any) { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + const isBackward = selection.isBackward(); + if (isBackward) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } else { + return $isAtNodeEnd(anchor) ? focusNode : anchorNode; + } +} + +function $isAtNodeEnd(point: any) { + return point.offset === point.getNode().getTextContentSize(); +} diff --git a/apps/console/src/pages/CreatePolicyPage.tsx b/apps/console/src/pages/CreatePolicyPage.tsx index 14ed93c8c..13d296161 100644 --- a/apps/console/src/pages/CreatePolicyPage.tsx +++ b/apps/console/src/pages/CreatePolicyPage.tsx @@ -6,10 +6,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/hooks/use-toast"; import { FileText } from "lucide-react"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import PolicyEditor from "@/components/PolicyEditor"; import type { CreatePolicyPageMutation } from "./__generated__/CreatePolicyPageMutation.graphql"; const CreatePolicyMutation = graphql` @@ -39,6 +39,13 @@ export default function CreatePolicyPage() { const [isSubmitting, setIsSubmitting] = useState(false); const { toast } = useToast(); + console.log( + "CreatePolicyPage state - content:", + content + ? content.substring(0, 50) + (content.length > 50 ? "..." : "") + : "empty" + ); + const [commitMutation] = useMutation(CreatePolicyMutation); @@ -136,14 +143,12 @@ export default function CreatePolicyPage() {
-