diff --git a/.storybook/vite.config.ts b/.storybook/vite.config.ts index 7c5b57f5c0..581770d782 100644 --- a/.storybook/vite.config.ts +++ b/.storybook/vite.config.ts @@ -5,5 +5,8 @@ import tsconfigPaths from "vite-tsconfig-paths"; import { presetHv } from "@hitachivantara/uikit-uno-preset"; export default defineConfig({ + optimizeDeps: { + exclude: ["xmllint-wasm"], + }, plugins: [react(), tsconfigPaths(), unoCSS({ presets: [presetHv()] })], }); diff --git a/package-lock.json b/package-lock.json index 9c5cd764d8..5a5a58685b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38279,6 +38279,14 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xmllint-wasm": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xmllint-wasm/-/xmllint-wasm-4.0.2.tgz", + "integrity": "sha512-tJxQzA9krv9gaQxYFJ/NVrpLf/wpWYPKjweCLMNbjooBTsW7/O26pyc3Pd0Lp0oTLUMFir/MEX3ybY/hhRjIpw==", + "engines": { + "node": ">=12.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -38524,7 +38532,8 @@ "dependencies": { "@hitachivantara/uikit-react-shared": "^5.2.0", "@hitachivantara/uikit-styles": "^5.31.1", - "@monaco-editor/react": "^4.5.1" + "@monaco-editor/react": "^4.5.1", + "xmllint-wasm": "^4.0.2" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/packages/code-editor/package.json b/packages/code-editor/package.json index eef891330c..93c4400884 100644 --- a/packages/code-editor/package.json +++ b/packages/code-editor/package.json @@ -40,7 +40,8 @@ "dependencies": { "@hitachivantara/uikit-react-shared": "^5.2.0", "@hitachivantara/uikit-styles": "^5.31.1", - "@monaco-editor/react": "^4.5.1" + "@monaco-editor/react": "^4.5.1", + "xmllint-wasm": "^4.0.2" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.stories.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.stories.tsx index f8031885c1..defd8e285c 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.stories.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.stories.tsx @@ -1,21 +1,13 @@ -import { useState } from "react"; -import { css } from "@emotion/css"; -import { Modal } from "@mui/material"; import { Meta, StoryObj } from "@storybook/react"; import { HvCodeEditor, HvCodeEditorProps, } from "@hitachivantara/uikit-react-code-editor"; -import { - HvIconButton, - HvTypography, - theme, -} from "@hitachivantara/uikit-react-core"; -import { - Duplicate, - Fullscreen, - PopUp, -} from "@hitachivantara/uikit-react-icons"; + +import { MainStory } from "./stories/Main"; +import MainStoryRaw from "./stories/Main?raw"; +import { XmlStory } from "./stories/Xml"; +import XmlStoryRaw from "./stories/Xml?raw"; const meta: Meta = { title: "Widgets/Code Editor", @@ -23,176 +15,15 @@ const meta: Meta = { }; export default meta; -const Header = (props: { - fileName: string; - handleOpen: (value: boolean) => void; -}) => { - const { fileName, handleOpen } = props; - - const styles = { - headerItemsWrapper: css({ - display: "flex", - alignItems: "center", - }), - codeEditorHeader: css({ - height: 50, - border: `1px solid ${theme.colors.atmo4}`, - borderBottom: "none", - background: theme.colors.atmo1, - padding: theme.spacing("xs", "xs", "xs", "sm"), - display: "flex", - justifyContent: "space-between", - }), - codeEditorFileName: css({ - margin: "5px 0px", - }), - codeEditorResetButton: css({ - float: "right", - }), - paper: css({ - position: "absolute", - width: "100%", - backgroundColor: "#FFF", - boxShadow: - "0px 3px 5px -1px rgba(0,0,0,0.2), 0px 5px 8px 0px rgba(0,0,0,0.14),0px 1px 14px 0px rgba(0,0,0,0.12)", - }), - buttonMargin: css({ - marginLeft: theme.spacing("xs"), - }), - }; - - return ( -
-
- - {fileName} - - - - -
-
- { - handleOpen(true); - }} - > - - - - - -
-
- ); -}; - -const defaultValueYaml = - 'affinity: {}\nconfiguration:\n helm: defaultTimeoutSeconds=120\nenv:\n debug: "true"\n hostname: foo.bar.com\nfullnameOverride: mySolution\nimage:\n pullPolicy: IfNotPresent\n repository: foo.bar.com:5000/app\n'; - -const defaultValueJson = `{ - "glossary": { - "title": "example glossary", - "GlossDiv": { - "title": "S", - "GlossList": { - "GlossEntry": { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "GlossDef": { - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": ["GML", "XML"] - }, - "GlossSee": "markup" - } - } - } - } - }`; - export const Main: StoryObj = { parameters: { // Enables Chromatic snapshot chromatic: { disableSnapshot: false, delay: 5000 }, + docs: { + source: { code: MainStoryRaw }, + }, }, - render: () => { - const getModalStyle = () => { - return { - top: `54%`, - left: `52%`, - transform: `translate(-52%, -54%)`, - }; - }; - - const [open, setOpen] = useState(false); - const [editorContent, setEditorContent] = useState(defaultValueJson); - const [modalStyle] = useState(getModalStyle); - - const handleClose = () => { - setOpen(false); - }; - - const styles = { - paper: css({ - position: "absolute", - width: "100%", - backgroundColor: "#FFF", - boxShadow: - "0px 3px 5px -1px rgba(0,0,0,0.2), 0px 5px 8px 0px rgba(0,0,0,0.14),0px 1px 14px 0px rgba(0,0,0,0.12)", - }), - }; - - const codeEditor = (height: number) => { - return ( - { - setEditorContent(input || ""); - }} - value={editorContent} - /> - ); - }; - - const body = ( -
- {codeEditor(500)} -
- ); - - return ( - <> -
- - {body} - - {codeEditor(420)} - - ); - }, + render: () => , }; export const YamlEditor: StoryObj = { @@ -206,13 +37,24 @@ export const YamlEditor: StoryObj = { chromatic: { disableSnapshot: false, delay: 5000 }, }, render: () => { + const defaultValueYaml = + 'affinity: {}\nconfiguration:\n helm: defaultTimeoutSeconds=120\nenv:\n debug: "true"\n hostname: foo.bar.com\nfullnameOverride: mySolution\nimage:\n pullPolicy: IfNotPresent\n repository: foo.bar.com:5000/app\n'; + return ( - console.log(input)} - value={defaultValueYaml} - /> + ); }, }; + +export const XMLEditor: StoryObj = { + parameters: { + docs: { + description: { + story: + "XML is one of the languages supported, and it can be enabled by setting the `language` property to `xml`. A XML schema can also be provided through the `xsdSchema` property. By providing a XML schema, the XML written will be validated against the schema showing errors. Providing a schema will also enable the code editor to show suggestions when opening a tag (`<`), writing an attribute, and when clicking on the CTRL and SPACE keys at the same time.", + }, + source: { code: XmlStoryRaw }, + }, + }, + render: () => , +}; diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 770fbb820a..fd1bb91f4c 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect } from "react"; -import { Editor, EditorProps, useMonaco } from "@monaco-editor/react"; +import { useCallback, useEffect, useRef } from "react"; +import { Editor, useMonaco, type EditorProps } from "@monaco-editor/react"; import { ExtractNames, useTheme } from "@hitachivantara/uikit-react-shared"; import { staticClasses, useClasses } from "./CodeEditor.styles"; +import { languagePlugins } from "./plugins"; export { staticClasses as codeEditorClasses }; @@ -17,6 +18,8 @@ export interface HvCodeEditorProps extends EditorProps { defaultValue?: string; /** The properties of the editor object in Monaco. */ options?: EditorProps["options"]; + /** XSD schema used to validate the code editor content when the language is set to `xml`. */ + xsdSchema?: string; } const defaultCodeEditorOptions: EditorProps["options"] = { @@ -37,34 +40,41 @@ const defaultCodeEditorOptions: EditorProps["options"] = { }; /** - * A wrapper to the React Monaco editor (https://github.com/suren-atoyan/monaco-react) with our styles. + * A wrapper to the [**React Monaco editor**](https://github.com/suren-atoyan/monaco-react) with our styles. * Please make sure you follow the instructions (found in the repository) to include the component. - * Additional information regarding Tab trapping in Monaco, can be found here: https://github.com/microsoft/monaco-editor/wiki/Monaco-Editor-Accessibility-Guide#tab-trapping. + * Additional information regarding Tab trapping in Monaco, can be found [**here**](https://github.com/microsoft/monaco-editor/wiki/Monaco-Editor-Accessibility-Guide#tab-trapping). */ export const HvCodeEditor = ({ classes: classesProp, - defaultValue, options, editorProps, + defaultLanguage, + language: languageProp, + xsdSchema, + onMount: onMountProp, + beforeMount: beforeMountProp, ...others }: HvCodeEditorProps) => { const { classes } = useClasses(classesProp); - const { colors, selectedMode, selectedTheme, colorModes } = useTheme(); + const language = languageProp ?? defaultLanguage; - const monaco = useMonaco(); + const editorRef = useRef(null); // Merges the 2 objects together, overriding defaults with passed in options - const mergedOptions = { + const mergedOptions: EditorProps["options"] = { ...defaultCodeEditorOptions, ...options, }; - const defineActiveThemes = useCallback(() => { - if (!monaco) return; + const { colors, selectedMode, selectedTheme, colorModes } = useTheme(); + const monacoInstance = useMonaco(); + + const handleActiveThemes = useCallback(() => { + if (!monacoInstance) return; colorModes.forEach((mode) => { - monaco?.editor.defineTheme(`hv-${selectedTheme}-${mode}`, { + monacoInstance?.editor.defineTheme(`hv-${selectedTheme}-${mode}`, { base: colors?.type === "light" ? "vs" : "vs-dark", inherit: true, rules: [], @@ -74,18 +84,85 @@ export const HvCodeEditor = ({ }, }); }); - }, [monaco, colorModes, selectedTheme, colors]); + }, [ + monacoInstance, + colorModes, + selectedTheme, + colors?.type, + colors?.atmo1, + colors?.secondary_60, + ]); useEffect(() => { - defineActiveThemes(); - }, [selectedTheme, colorModes, defineActiveThemes]); + handleActiveThemes(); + }, [handleActiveThemes]); + + const handleBeforeMount: EditorProps["beforeMount"] = (monaco) => { + beforeMountProp?.(monaco); + handleActiveThemes(); + }; + + const handleMount: EditorProps["onMount"] = (editor, monaco) => { + editorRef.current = editor; + onMountProp?.(editor, monaco); + + // Get language plugin + const languagePlugin = language ? languagePlugins[language] : undefined; + + if (!languagePlugin) return; + + // Register language + monaco.languages.register({ id: language }); + + const { + completionProvider, + editorOptions, + keyDownListener, + validationMarker, + } = languagePlugin; + + // Update options + if (editorOptions) + editor.updateOptions({ + ...options, + ...editorOptions, + }); + + // Register completion provider + if (completionProvider) + monaco.languages.registerCompletionItemProvider( + language, + completionProvider(monaco, xsdSchema), + ); + + // Validate content and get error markers + if (validationMarker) + editor.onDidChangeModelContent(async () => { + const model = editor.getModel(); + const content = model.getValue(); + const validation = await validationMarker( + content, + editor, + monaco, + xsdSchema, + ); + monaco.editor.setModelMarkers(model, language, validation); + }); + + // Listen for key down events + if (keyDownListener) + editor.onKeyDown((event: any) => keyDownListener(event, editor, monaco)); + }; return (
diff --git a/packages/code-editor/src/CodeEditor/languages/xml.ts b/packages/code-editor/src/CodeEditor/languages/xml.ts new file mode 100644 index 0000000000..184b7785f1 --- /dev/null +++ b/packages/code-editor/src/CodeEditor/languages/xml.ts @@ -0,0 +1,310 @@ +import { type Monaco } from "@monaco-editor/react"; +import { validateXML } from "xmllint-wasm"; + +// Helpful notes +// model - editor content +// position - position of the pointer + +type Elements = Record; // element name (key) / attributes (value) + +/** + * Gets elements and their attributes from XSD schema. + */ +const getXsdElementsAndAttributes = (value: string): Elements => { + const elements: Elements = {}; + try { + // Parse XSD schema string into a DOM object + const parser = new DOMParser(); + const schemaDoc = parser.parseFromString(value, "application/xml"); + + // Get all elements and their attributes + for (const element of schemaDoc.getElementsByTagName("xs:element")) { + const elementName = element.getAttribute("name"); + if (elementName) { + const attributes = element.getElementsByTagName("xs:attribute"); + if (attributes && attributes.length > 0) { + const elementAttributes = []; + for (const attribute of attributes) { + const attributeName = attribute.getAttribute("name"); + if (attributeName) elementAttributes.push(attributeName); + } + elements[elementName] = elementAttributes; + } else elements[elementName] = undefined; + } + } + } catch (error) { + // eslint-disable-next-line no-console + if (import.meta.env.DEV) console.error(error); + } + return elements; +}; + +/** + * Gets the last opened (but not closed) tag to suggest attributes + * Source code from: https://mono.software/2017/04/11/custom-intellisense-with-monaco-editor/ + */ +const getLastOpenedTag = (content: string) => { + // Get all tags inside of the content + const tags = content.match(/<\/*(?=\S*)([a-zA-Z-]+)/g); + if (!tags) return undefined; + + // Find closed tags + const closingTags = []; + + for (let i = tags.length - 1; i >= 0; i--) { + if (tags[i].indexOf("", tagPosition); + + if (closingBracketIdx === -1) { + // If there are no closing tags or the current tag wasn't closed + if ( + !closingTags.length || + closingTags[closingTags.length - 1] !== tag + ) { + // Last open tag found + content = content.substring(tagPosition); + return content.indexOf(">") === -1 ? tag : undefined; + } + + // Remove the last closed tag since it + closingTags.splice(closingTags.length - 1, 1); + } + + // Remove the last closed tag and continue processing the rest of the content + content = content.substring(0, tagPosition); + } + } +}; + +/** + * Triggers suggestions for specific cases. + */ +export const getXmlCompletionProvider = (monaco: Monaco, schema?: string) => { + const elements = schema ? getXsdElementsAndAttributes(schema) : undefined; + const root = elements ? Object.keys(elements)[0] : undefined; + const emptySuggestions = root + ? [ + /** Add root element to the editor */ + { + label: "Insert root element", + kind: monaco.languages.CompletionItemKind.Snippet, + // eslint-disable-next-line no-template-curly-in-string + insertText: `<${root}>\n\t${"${0:}"}\n`, // ${0:} used to position the cursor + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + }, + ] + : []; + + return { + triggerCharacters: ["<"], + provideCompletionItems: (model: any, position: any) => { + // Suggestions to show when there's nothing written (by using keys or typing a trigger characters) + const fullContent = model.getValue(); + if (!String(fullContent).trim()) { + return { + suggestions: emptySuggestions.map((sug) => ({ + ...sug, + range: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1, + }, + })), + }; + } + + const suggestions: object[] = []; + const lastWordWritten = model.getWordUntilPosition(position); + + // Suggestions to show when opening a tag (by typing "<") + const lastTypedChar = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: position.column - 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + if (lastTypedChar === "<" && elements) { + suggestions.push( + ...Object.keys(elements).map((element) => ({ + label: element, + kind: monaco.languages.CompletionItemKind.Field, + insertText: element, + range: { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column, + }, + })), + ); + } + + // Suggestions to show when looking for attributes in a tag (by using keys) + const textUntilCursor = String( + model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }), + ); + const lastOpenedTag = getLastOpenedTag(textUntilCursor); + if ( + lastOpenedTag && + elements?.[lastOpenedTag] && + elements[lastOpenedTag].length > 0 + ) { + const attrs = lastWordWritten.word + ? elements[lastOpenedTag].filter((attr) => + attr.startsWith(lastWordWritten.word), + ) + : elements[lastOpenedTag]; + + suggestions.push( + ...attrs.map((atr) => ({ + label: atr, + kind: monaco.languages.CompletionItemKind.Field, + // eslint-disable-next-line no-template-curly-in-string + insertText: `${atr}="${"${0:}"}"`, // ${0:} used to position the cursor + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range: { + startLineNumber: position.lineNumber, + startColumn: lastWordWritten.startColumn, + endLineNumber: position.lineNumber, + endColumn: lastWordWritten.endColumn, + }, + })), + ); + } + + return { + suggestions, + }; + }, + }; +}; + +/** + * Validates XML with XSD schema and create error markers to show on code editor. + */ +export const getXmlValidationMarkers = async ( + content: string, + editor: any, + monaco: Monaco, + schema = "", +) => { + const model = editor.getModel(); + + try { + const validation = await validateXML({ xml: content, schema }); + if (!validation.valid) { + return validation.errors.map((error) => { + return { + severity: monaco.MarkerSeverity.Error, + message: error.message, + startLineNumber: error.loc?.lineNumber ?? 1, + startColumn: + model.getLineFirstNonWhitespaceColumn(error.loc?.lineNumber) ?? 1, + endLineNumber: error.loc?.lineNumber ?? 1, + endColumn: + model.getLineLastNonWhitespaceColumn(error.loc?.lineNumber) ?? 1, + }; + }); + } + } catch (error: any) { + const value = model.getValue(); + if (!String(value).trim()) return []; + + const errors = String(error?.message).split("parser error :"); + const lastError = errors[errors.length - 1].trim(); + const lineNumberParts = lastError.match(/(line) ([0-9]+)/); + const errorLine = Number(lineNumberParts?.[2]) ?? 1; + const cleanedError = lastError.replace(lineNumberParts?.[0] ?? "", ""); + + return [ + { + severity: monaco.MarkerSeverity.Error, + message: cleanedError, + startLineNumber: errorLine, + startColumn: model.getLineFirstNonWhitespaceColumn(errorLine) ?? 1, + endLineNumber: errorLine, + endColumn: model.getLineLastNonWhitespaceColumn(errorLine) ?? 1, + }, + ]; + } + return []; +}; + +/** + * Auto-completes a tag when closing it: writing ">" after "" + * Source code from: https://github.com/microsoft/monaco-editor/issues/221 + */ +export const handleXmlKeyDown = (event: any, editor: any, monaco: Monaco) => { + if (event.browserEvent.key === ">") { + const model = editor.getModel(); + const edits: any[] = []; + const selections: any[] = []; + + for (const selection of editor.getSelections()) { + // Shift the selection over by one to account for the new character + selections.push( + new monaco.Selection( + selection.selectionStartLineNumber, + selection.selectionStartColumn + 1, + selection.endLineNumber, + selection.endColumn + 1, + ), + ); + + // Line before the cursor + const lineBeforeChange = model.getValueInRange({ + startLineNumber: selection.endLineNumber, + startColumn: 1, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn, + }); + + // Look for the tag we are currently closing + const tag = String(lineBeforeChange).match( + /<([\w-]+)(?![^>]*\/>)[^>]*$/, + )?.[1]; + if (tag) { + // Add the closing tag + edits.push({ + range: { + startLineNumber: selection.endLineNumber, + startColumn: selection.endColumn + 1, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn + 1, + }, + text: ``, + }); + } + } + + // Wait for next tick to being an invalid operation + setTimeout(() => { + editor.executeEdits(model.getValue(), edits, selections); + }, 0); + } +}; + +/** XML custom options. */ +export const xmlOptions = { + formatOnType: true, + formatOnPaste: true, + autoClosingBrackets: false, + tabSize: 2, + autoIndent: "full", +}; diff --git a/packages/code-editor/src/CodeEditor/plugins.ts b/packages/code-editor/src/CodeEditor/plugins.ts new file mode 100644 index 0000000000..f6980820cc --- /dev/null +++ b/packages/code-editor/src/CodeEditor/plugins.ts @@ -0,0 +1,40 @@ +import { type Monaco } from "@monaco-editor/react"; + +import { + getXmlCompletionProvider, + getXmlValidationMarkers, + handleXmlKeyDown, + xmlOptions, +} from "./languages/xml"; + +type CompletionProvider = ( + monaco: Monaco, + schema?: string, // needed for XML language +) => object; +type ValidationMarker = ( + content: string, + editor: any, + monaco: Monaco, + schema?: string, // needed for XML language +) => Promise; +type KeyDownListener = (event: any, editor: any, monaco: Monaco) => void; + +interface LanguagePlugin { + completionProvider?: CompletionProvider; + validationMarker?: ValidationMarker; + keyDownListener?: KeyDownListener; + editorOptions?: object; +} + +const xmlLanguagePlugin = (): LanguagePlugin => { + return { + completionProvider: getXmlCompletionProvider, + validationMarker: getXmlValidationMarkers, + keyDownListener: handleXmlKeyDown, + editorOptions: xmlOptions, + }; +}; + +export const languagePlugins: Record = { + xml: xmlLanguagePlugin(), +}; diff --git a/packages/code-editor/src/CodeEditor/stories/Main.tsx b/packages/code-editor/src/CodeEditor/stories/Main.tsx new file mode 100644 index 0000000000..14c55157ef --- /dev/null +++ b/packages/code-editor/src/CodeEditor/stories/Main.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { css } from "@emotion/css"; +import { HvCodeEditor } from "@hitachivantara/uikit-react-code-editor"; +import { + HvDialog, + HvDialogContent, + HvDialogTitle, + HvIconButton, + HvTypography, + theme, +} from "@hitachivantara/uikit-react-core"; +import { + Duplicate, + Fullscreen, + PopUp, +} from "@hitachivantara/uikit-react-icons"; + +const styles = { + actionsContainer: css({ + display: "flex", + alignItems: "center", + gap: theme.space.xs, + }), + codeEditorHeader: css({ + display: "flex", + justifyContent: "space-between", + height: 50, + border: `1px solid ${theme.colors.atmo4}`, + borderBottom: "none", + background: theme.colors.atmo1, + padding: theme.space.xs, + }), +}; + +const Header = ({ title, onOpen }: { title: string; onOpen: () => void }) => ( +
+
+ + {title} + + + + +
+
+ + + + + + +
+
+); + +const defaultValueJson = `{ + "glossary": { + "title": "example glossary", + "GlossDiv": { + "title": "S", + "GlossList": { + "GlossEntry": { + "ID": "SGML", + "SortAs": "SGML", + "GlossTerm": "Standard Generalized Markup Language", + "Acronym": "SGML", + "Abbrev": "ISO 8879:1986", + "GlossDef": { + "para": "A meta-markup language, used to create markup languages such as DocBook.", + "GlossSeeAlso": ["GML", "XML"] + }, + "GlossSee": "markup" + } + } + } + } +}`; + +export const MainStory = () => { + const [open, setOpen] = useState(false); + const [editorContent, setEditorContent] = useState(defaultValueJson); + + const renderCodeEditor = (height: number) => ( + { + setEditorContent(input || ""); + }} + value={editorContent} + /> + ); + + return ( + <> +
setOpen(true)} /> + {renderCodeEditor(420)} + setOpen(false)} + > + FullScreen + {renderCodeEditor(500)} + + + ); +}; diff --git a/packages/code-editor/src/CodeEditor/stories/Xml/Xml.tsx b/packages/code-editor/src/CodeEditor/stories/Xml/Xml.tsx new file mode 100644 index 0000000000..e9e1cb8222 --- /dev/null +++ b/packages/code-editor/src/CodeEditor/stories/Xml/Xml.tsx @@ -0,0 +1,118 @@ +import { useRef, useState } from "react"; +import { + HvCodeEditor, + HvCodeEditorProps, +} from "@hitachivantara/uikit-react-code-editor"; +import { + HvButtonProps, + HvDialog, + HvDialogContent, + HvDialogTitle, + HvTreeView, +} from "@hitachivantara/uikit-react-core"; + +// The code for these utils can be found at: https://github.com/lumada-design/hv-uikit-react/tree/master/packages/code-editor/src/CodeEditor/stories/Xml/utils.tsx +import { Attributes, buildTree, Header, renderItem, Tree } from "./utils"; + +const xsdSchema = ` + + + + + + + + + + + + + + + + + + +`; + +const defaultValue = ` + + Demon Copperhead + Barbara Kingsolver + 2022 + Historical Fiction + + + Assassin's Apprentice + Robin Hobb + 1995 + Fantasy + +`; + +export const XmlStory = () => { + const [opened, setOpened] = useState(false); + const [editorValue, setEditorValue] = useState(defaultValue); + const [xmlTree, setXmlTree] = useState<{ + tree: Tree; + attributes: Attributes; + }>(); + const [defaultExpandedKeys, setDefaultExpandedKeys] = useState([]); + + const editorRef = useRef(null); + + const handleMount: HvCodeEditorProps["onMount"] = (editor) => { + editorRef.current = editor; + }; + + const handleOpenSearch: HvButtonProps["onClick"] = () => { + editorRef.current?.getAction("actions.find").run(); + }; + + const handleClickTree: HvButtonProps["onClick"] = () => { + const { tree, attributes, keys } = buildTree(editorValue); + setXmlTree({ + tree, + attributes, + }); + setDefaultExpandedKeys(keys); + setOpened(true); + }; + + return ( +
+
+ setEditorValue(content ?? "")} + xsdSchema={xsdSchema} + onMount={handleMount} + /> + setOpened(false)} + > + XML Tree Structure + + {xmlTree?.tree && ( + + {Object.entries(xmlTree.tree).map(([key, value]) => + renderItem(key, value, xmlTree.attributes), + )} + + )} + + +
+ ); +}; diff --git a/packages/code-editor/src/CodeEditor/stories/Xml/index.ts b/packages/code-editor/src/CodeEditor/stories/Xml/index.ts new file mode 100644 index 0000000000..bf6e749e98 --- /dev/null +++ b/packages/code-editor/src/CodeEditor/stories/Xml/index.ts @@ -0,0 +1 @@ +export * from "./Xml"; diff --git a/packages/code-editor/src/CodeEditor/stories/Xml/utils.tsx b/packages/code-editor/src/CodeEditor/stories/Xml/utils.tsx new file mode 100644 index 0000000000..b973cdac84 --- /dev/null +++ b/packages/code-editor/src/CodeEditor/stories/Xml/utils.tsx @@ -0,0 +1,197 @@ +import { forwardRef } from "react"; +import { css } from "@emotion/css"; +import { + HvButton, + HvButtonProps, + HvTooltip, + HvTreeItem, + HvTreeItemProps, + HvTypography, + theme, + uniqueId, +} from "@hitachivantara/uikit-react-core"; +import { Code, Info } from "@hitachivantara/uikit-react-icons"; + +export const buildTree = (xml: string) => { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xml, "application/xml"); + + const allKeys: string[] = []; + const attributes: Attributes = {}; + + const traverse = (node: HTMLElement | ChildNode) => { + const structure: Tree = {}; + const { nodeName } = node; + const nodeId = uniqueId(); + const nodeKey = `${nodeName}_${nodeId}`; + + allKeys.push(nodeKey); + structure[nodeKey] = undefined; + + // Get attributes for node + if ((node as any).attributes) { + for (let i = 0; i < (node as any).attributes.length; i++) { + const attr = (node as any).attributes[i]; + attributes[nodeKey] = { + ...attributes[nodeKey], + [attr.name]: attr.value, + }; + } + } + + // Get tree structure + for (const child of node.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE) { + const childStructure = traverse(child); + + const found = Object.keys(structure).find( + (key) => key.split("_")[0] === nodeName, + ); + + if (found && structure[found]) { + if (!Array.isArray(structure[found])) { + structure[found] = [structure[found]]; + } + if (Array.isArray(structure[found])) + structure[found].push(childStructure); + } else { + structure[nodeKey] = childStructure; + } + } else if (child.nodeType === Node.TEXT_NODE && child.nodeValue?.trim()) { + structure[nodeKey] = child.nodeValue.trim(); + } + } + return structure; + }; + + return { + tree: traverse(xmlDoc.documentElement), + keys: allKeys, + attributes, + }; +}; + +const classes = { + headerRoot: css({ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + border: `1px solid ${theme.colors.atmo4}`, + borderBottom: "none", + background: theme.colors.atmo1, + padding: theme.spacing("xs", "sm"), + gap: theme.space.xs, + }), + label: css({ display: "flex", alignItems: "center" }), +}; + +export const Header = ({ + onClickSearch, + onClickTree, +}: { + onClickSearch: HvButtonProps["onClick"]; + onClickTree: HvButtonProps["onClick"]; +}) => ( +
+ XML + +
+ + Search + + + XML Tree + +
+); + +export type Tree = Record; + +export type Attributes = Record>; + +interface SimpleTreeItemProps extends HvTreeItemProps { + attributes: Attributes; +} + +const SimpleTreeItem = forwardRef( + (props, ref) => { + const { children, nodeId, label, attributes, ...others } = props; + return ( + + {label} + {attributes[nodeId] && ( + `${key}: ${value}`) + .join("; ")} + > + + + )} +
+ ) : ( + label + ) + } + {...others} + > + {children} + + ); + }, +); + +export const renderItem = ( + key: string, + value: object | object[] | string | undefined, + attributes: Attributes, +) => { + const label = key.split("_")[0]; + + if (Array.isArray(value)) { + return ( + + {value.map((childValue) => { + const childKey = Object.keys(childValue)[0]; + return renderItem(childKey, childValue[childKey], attributes); + })} + + ); + } + + if (typeof value === "object") { + return ( + + {Object.entries(value).map(([childKey, childValue]) => + renderItem(childKey, childValue, attributes), + )} + + ); + } + + return ( + + {value} + + ); +};