From f90595ce0ff0c2198626894989a3eccbb1e5bad4 Mon Sep 17 00:00:00 2001 From: ZuperZee Date: Fri, 13 Jun 2025 12:32:44 +0200 Subject: [PATCH] feat: add adjustable height for the code editor The height is adjusted with the "resize" css style. Most browsers support it, but some, like Safari, do not. The default height is also lowered to 64px mainly to make scrolling easier. I also found it a lot easier to get an overview and navigate through the options with the smaller editor height :D --- .../CodeEditor/HeightControllerBar.tsx | 118 +++++++++++++++++ src/components/CodeEditor/index.tsx | 124 ++++++++++++++---- 2 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 src/components/CodeEditor/HeightControllerBar.tsx diff --git a/src/components/CodeEditor/HeightControllerBar.tsx b/src/components/CodeEditor/HeightControllerBar.tsx new file mode 100644 index 0000000..3837593 --- /dev/null +++ b/src/components/CodeEditor/HeightControllerBar.tsx @@ -0,0 +1,118 @@ +import React, { type RefObject } from 'react'; +import { css } from '@emotion/css'; +import { IconButton, Text, Tooltip, useStyles2 } from '@grafana/ui'; +import type { GrafanaTheme2 } from '@grafana/data'; + +function getShrinkIcon( + editorHeight: number | undefined, + containerHeight: number | undefined, + viewHeight33InPx: number +) { + if (editorHeight === undefined || containerHeight === undefined) { + return 'arrow-up'; + } + if (editorHeight === 64) { + return 'arrow-left'; + } + if (editorHeight < 64) { + return 'arrow-down'; + } + if (viewHeight33InPx < Math.round(containerHeight)) { + return 'angle-double-up'; + } + return 'arrow-up'; +} + +function getExpandIcon( + editorHeight: number | undefined, + containerHeight: number | undefined, + viewHeight33InPx: number +) { + if (editorHeight === undefined || containerHeight === undefined) { + return 'arrow-down'; + } + if (viewHeight33InPx === Math.round(containerHeight)) { + return 'arrow-left'; + } + if (viewHeight33InPx < Math.round(containerHeight)) { + return 'arrow-up'; + } + if (editorHeight < 64) { + return 'angle-double-down'; + } + return 'arrow-down'; +} + +export function HeightControllerBar({ + containerRef, + editorHeight, + containerHeight, +}: { + containerRef: RefObject; + editorHeight: number | undefined; + containerHeight: number | undefined; +}) { + const styles = useStyles2(getStyles); + + const viewHeight33InPx = Math.round(window.innerHeight * 0.33); + const editorHeightOffset = (containerHeight && editorHeight && containerHeight - editorHeight) ?? 0; + + const shrinkIcon = getShrinkIcon(editorHeight, containerHeight, viewHeight33InPx); + const expandIcon = getExpandIcon(editorHeight, containerHeight, viewHeight33InPx); + + const actuallySetContainerHeight = (height: string) => { + if (!containerRef.current) { + return; + } + containerRef.current.style.height = height; + }; + + return ( +
+ +
+ + {editorHeight === 5 ? '(min) ' : ''} + {editorHeight === 64 ? '(default) ' : ''} + {editorHeight !== undefined ? editorHeight.toFixed() + 'px' : ''} + +
+
+
+ actuallySetContainerHeight(`${64 + editorHeightOffset}px`)} + /> + actuallySetContainerHeight('33vh')} + /> +
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + bar: css` + display: flex; + align-items: center; + justify-content: end; + padding-right: 14px; + gap: 8px; + border: 1px solid ${theme.colors.border.weak}; + border-top: none; + `, + heightText: css` + height: 24px; + display: flex; + gap: 4px; + `, + }; +} diff --git a/src/components/CodeEditor/index.tsx b/src/components/CodeEditor/index.tsx index 5b7b9b6..279dcd4 100644 --- a/src/components/CodeEditor/index.tsx +++ b/src/components/CodeEditor/index.tsx @@ -1,7 +1,9 @@ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useEffect, useLayoutEffect, useState, useRef, type RefObject } from 'react'; import { CodeEditorOptionSettings, OptionsInterface } from 'types'; -import { CodeEditor as GrafanaCodeEditor, Monaco, MonacoEditor } from '@grafana/ui'; -import { StandardEditorContext } from '@grafana/data'; +import { CodeEditor as GrafanaCodeEditor, Monaco, MonacoEditor, useStyles2 } from '@grafana/ui'; +import { StandardEditorContext, type GrafanaTheme2 } from '@grafana/data'; +import { HeightControllerBar } from './HeightControllerBar'; +import { css } from '@emotion/css'; interface Props { settings?: CodeEditorOptionSettings; @@ -10,24 +12,63 @@ interface Props { onChange: (value: string) => void; } -export const CodeEditor: FC = ({ settings, value, context, onChange }) => { - const [monaco, setMonaco] = useState(); +const EDITOR_BORDER_SIZE = 2; // Grafana has a 1px border on the editor container +const BAR_HEIGHT = 24; +const BAR_BORDER_SIZE = 1; // The bar only has a bottom border +const EDITOR_HEIGHT_OFFSET = EDITOR_BORDER_SIZE + BAR_HEIGHT + BAR_BORDER_SIZE; // 2px + 24px + 1px = 27px - const editorDidMount = async (_: MonacoEditor, m: Monaco) => { - setMonaco(m); - }; +function useSizeController(containerRef: RefObject, editor: MonacoEditor | undefined) { + const [containerSize, setContainerSize] = useState<{ height: number; width: number }>(); + const [editorSize, setEditorSize] = useState<{ height: number; width: number }>(); + + useLayoutEffect(() => { + if (!containerRef.current) { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + if (entries.length !== 1) { + return; + } + const entry = entries[0]; + + const editorHeight = entry.contentRect.height - EDITOR_HEIGHT_OFFSET; + const editorWidth = entry.contentRect.width - EDITOR_BORDER_SIZE; + + setContainerSize({ height: entry.contentRect.height, width: entry.contentRect.width }); + setEditorSize({ height: editorHeight, width: editorWidth }); + + if (editor) { + editor.layout({ height: editorHeight, width: editorWidth }); + } + }); + + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [containerRef, editor]); + + return { containerSize, editorSize }; +} +function useTypeDeclarationUpdater( + monaco: Monaco | undefined, + htmlGraphicsDeclarationState: CodeEditorOptionSettings['htmlGraphicsDeclarationState'] | undefined, + codeData: string | undefined +) { useEffect(() => { - if (!monaco || !settings?.htmlGraphicsDeclarationState?.enabled) { + if (!monaco || !htmlGraphicsDeclarationState?.enabled) { return; } - if (settings.htmlGraphicsDeclarationState.declarationsLoaded) { + if (htmlGraphicsDeclarationState.declarationsLoaded) { // onInit and onRender would normally both load the declarations, // but this makes only one of them load the declarations return; } else { - settings.htmlGraphicsDeclarationState.declarationsLoaded = true; + htmlGraphicsDeclarationState.declarationsLoaded = true; } const reqDecl = require.context('./declarations', true, /\..*\.d\.ts$/); @@ -53,17 +94,17 @@ export const CodeEditor: FC = ({ settings, value, context, onChange }) => monaco.languages.typescript.javascriptDefaults.addExtraLib(d[i], truncatedPath); }); }); - }, [monaco, settings?.htmlGraphicsDeclarationState]); + }, [monaco, htmlGraphicsDeclarationState]); useEffect(() => { - if (!monaco || context.options?.codeData === undefined || !settings?.htmlGraphicsDeclarationState?.enabled) { + if (!monaco || codeData === undefined || !htmlGraphicsDeclarationState?.enabled) { return; } - if (settings.htmlGraphicsDeclarationState.handlingCustomPropertiesUpdate) { + if (htmlGraphicsDeclarationState.handlingCustomPropertiesUpdate) { return; } else { - settings.htmlGraphicsDeclarationState.handlingCustomPropertiesUpdate = true; + htmlGraphicsDeclarationState.handlingCustomPropertiesUpdate = true; } const createCustomPropertiesType = (json: string) => { @@ -78,28 +119,67 @@ export const CodeEditor: FC = ({ settings, value, context, onChange }) => } }; - const content = createCustomPropertiesType(context.options.codeData); + const content = createCustomPropertiesType(codeData); monaco.languages.typescript.javascriptDefaults.addExtraLib(content, 'customProperties.d.ts'); return () => { - if (!settings.htmlGraphicsDeclarationState) { + if (!htmlGraphicsDeclarationState) { return; } - settings.htmlGraphicsDeclarationState.handlingCustomPropertiesUpdate = false; + htmlGraphicsDeclarationState.handlingCustomPropertiesUpdate = false; }; - }, [monaco, settings?.htmlGraphicsDeclarationState, context.options?.codeData]); + }, [monaco, htmlGraphicsDeclarationState, codeData]); +} + +export const CodeEditor: FC = ({ settings, value, context, onChange }) => { + const styles = useStyles2( + getStyles, + `${64 + EDITOR_HEIGHT_OFFSET}px` // // 64px + 27px = 91px + ); + + const [monaco, setMonaco] = useState(); + const [editor, setEditor] = useState(); + + const containerRef = useRef(null); + + const { containerSize, editorSize } = useSizeController(containerRef, editor); + useTypeDeclarationUpdater(monaco, settings?.htmlGraphicsDeclarationState, context.options?.codeData); + + const editorDidMount = async (e: MonacoEditor, m: Monaco) => { + e.layout(); + setMonaco(m); + setEditor(e); + }; return ( -
+
+
); }; + +function getStyles(_theme: GrafanaTheme2, defaultHeight: string) { + return { + container: css` + resize: vertical; + overflow: hidden; + height: ${defaultHeight}; + min-height: 32px; + display: flex; + flex-direction: column; + justify-content: space-between; + `, + }; +}