diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f47a111f..78466ca43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,12 +15,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- component for hiding elements in specific media
- ``
- force children to get displayed as inline content
- - ``
+- ``
- similar to `ContextOverlay` component but not directly linked to a React element, it specifies the target in the DOM to get connected lazy
- ``
- `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly`
- ``
- `paddingSize` property to add easily some white space
+- ``
+ - toolbar in `markdown` mode provides a user config menu for the editor appearance
- CSS custom properties
- beside the color palette we now mirror the most important layout configuration variables as CSS custom properties
- new icons:
@@ -49,7 +51,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- ``
- use the latest provided `onChange` function
- ``, ``
- - fix emoji false-positives in invisible character detection
+ - fix emoji false-positives in invisible character detection
### Changed
@@ -57,6 +59,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Change default filter predicate to match multi-word queries.
- ``
- reduce stroke width to only 1px
+- ``
+ - `wrapLines` and `preventLineNumber` do use `false` default value but if not set then it will be interpreted as `false`
+ - in this way it can be overwritten by new user config for the markdown mode
- automatically hide user interaction elements in print view
- all application header components except ``
- `` and ``
diff --git a/src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx b/src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx
index 924cb7e51..594b6f94c 100644
--- a/src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx
+++ b/src/cmem/react-flow/StickyNoteModal/StickyNoteModal.tsx
@@ -130,7 +130,7 @@ export const StickyNoteModal: React.FC = React.memo(
name={translate("noteLabel")}
id={"sticky-note-input"}
mode="markdown"
- preventLineNumbers
+ useToolbar
onChange={(value) => {
refNote.current = value;
}}
diff --git a/src/extensions/codemirror/CodeMirror.stories.tsx b/src/extensions/codemirror/CodeMirror.stories.tsx
index b3d8ce75a..e0c2d72cd 100644
--- a/src/extensions/codemirror/CodeMirror.stories.tsx
+++ b/src/extensions/codemirror/CodeMirror.stories.tsx
@@ -24,17 +24,22 @@ const TemplateFull: StoryFn = (args) => , "translate" | "onChange" | "onKeyDown" | "onMouseDown" | "onScroll">, TestableComponent {
+interface EditorAppearance {
+ /**
+ * If enabled the code editor won't show numbers before each line.
+ */
+ preventLineNumbers?: boolean;
+
+ /** Long lines are wrapped and displayed on multiple lines */
+ wrapLines?: boolean;
+}
+
+export interface CodeEditorProps extends EditorAppearance, Omit, "translate" | "onChange" | "onKeyDown" | "onMouseDown" | "onScroll">, TestableComponent {
// Is called with the editor instance that allows access via the CodeMirror API
setEditorView?: (editor: EditorView | undefined) => void;
/**
@@ -86,10 +97,6 @@ export interface CodeEditorProps extends Omit, "id" | "data-test-id" | "data-testid" | "translate" | "onChange" | "onKeyDown" | "onMouseDown" | "onScroll">;
@@ -186,6 +190,18 @@ const ModeLinterMap: ReadonlyMap = ["markdown"];
+const defaultAppearanceForModeWithToolbar: ReadonlyMap = new Map([
+ ["markdown", { wrapLines: true, preventLineNumbers: true }]
+]);
+
+const getDefaultAppearanceForModeWithToolbar = (hasToolbar: boolean, mode?: SupportedCodeEditorModes): EditorAppearance | undefined => {
+ if (hasToolbar && mode) {
+ return defaultAppearanceForModeWithToolbar.get(mode);
+ }
+
+ return undefined;
+}
+
/**
* Includes a code editor, currently we use CodeMirror library as base.
*/
@@ -200,11 +216,11 @@ export const CodeEditor = ({
name,
id,
mode,
- preventLineNumbers = false,
+ preventLineNumbers,
+ wrapLines,
defaultValue = "",
readOnly = false,
shouldHaveMinimalSetup = true,
- wrapLines = false,
onScroll,
setEditorView,
supportCodeFolding = false,
@@ -221,12 +237,20 @@ export const CodeEditor = ({
autoFocus = false,
disabled = false,
intent,
- useToolbar,
+ useToolbar = false,
translate,
...otherCodeEditorProps
}: CodeEditorProps) => {
const parent = useRef(undefined);
const [view, setView] = React.useState();
+ const defaultAppearanceForModeWithToolbar = getDefaultAppearanceForModeWithToolbar(useToolbar, mode);
+ const [editorAppearance, setEditorAppearance] = React.useState<{[s: string]: boolean;}>(
+ {
+ // we also set the fallback default here
+ wrapLines: wrapLines ?? defaultAppearanceForModeWithToolbar?.wrapLines ?? false,
+ preventLineNumbers: preventLineNumbers ?? defaultAppearanceForModeWithToolbar?.preventLineNumbers ?? false,
+ }
+ )
const currentView = React.useRef()
currentView.current = view
const currentReadOnly = React.useRef(readOnly)
@@ -235,6 +259,8 @@ export const CodeEditor = ({
currentOnChange.current = onChange
const currentDisabled = React.useRef(disabled)
currentDisabled.current = disabled
+ const currentIntent = React.useRef(intent)
+ currentIntent.current = intent
const [showPreview, setShowPreview] = React.useState(false);
// CodeMirror Compartments in order to allow for re-configuration after initialization
const readOnlyCompartment = React.useRef(compartment())
@@ -333,8 +359,8 @@ export const CodeEditor = ({
if (onSelection)
onSelection(v.state.selection.ranges.filter((r) => !r.empty).map(({ from, to }) => ({ from, to })));
- if (onFocusChange && intent && !v.view.dom.classList?.contains(`${eccgui}-intent--${intent}`)) {
- v.view.dom.classList.add(`${eccgui}-intent--${intent}`);
+ if (onFocusChange && currentIntent.current && !v.view.dom.classList?.contains(`${eccgui}-intent--${currentIntent.current}`)) {
+ v.view.dom.classList.add(`${eccgui}-intent--${currentIntent.current}`);
}
if (onCursorChange) {
@@ -357,9 +383,9 @@ export const CodeEditor = ({
}
}),
shouldHaveMinimalSetupCompartment.current.of(addExtensionsFor(shouldHaveMinimalSetup, minimalSetup)),
- preventLineNumbersCompartment.current.of(addExtensionsFor(!preventLineNumbers, adaptedLineNumbers())),
+ preventLineNumbersCompartment.current.of(addExtensionsFor(!editorAppearance.preventLineNumbers, adaptedLineNumbers())),
shouldHighlightActiveLineCompartment.current.of(addExtensionsFor(shouldHighlightActiveLine, adaptedHighlightActiveLine())),
- wrapLinesCompartment.current.of(addExtensionsFor(wrapLines, EditorView?.lineWrapping)),
+ wrapLinesCompartment.current.of(addExtensionsFor((editorAppearance.wrapLines!), EditorView?.lineWrapping)),
supportCodeFoldingCompartment.current.of(addExtensionsFor(supportCodeFolding, adaptedFoldGutter(), adaptedCodeFolding())),
useLintingCompartment.current.of(addExtensionsFor(useLinting, ...linters)),
adaptedSyntaxHighlighting(defaultHighlightStyle),
@@ -384,8 +410,8 @@ export const CodeEditor = ({
view.dom.classList.add(`${eccgui}-disabled`);
}
- if (intent) {
- view.dom.className += ` ${eccgui}-intent--${intent}`;
+ if (currentIntent.current) {
+ view.dom.className += ` ${eccgui}-intent--${currentIntent.current}`;
}
if (autoFocus) {
@@ -447,20 +473,28 @@ export const CodeEditor = ({
}, [disabled])
React.useEffect(() => {
- updateExtension(addExtensionsFor(shouldHaveMinimalSetup ?? true, minimalSetup), shouldHaveMinimalSetupCompartment.current)
- }, [shouldHaveMinimalSetup])
+ setEditorAppearance({
+ ...editorAppearance,
+ preventLineNumbers: preventLineNumbers ?? editorAppearance?.preventLineNumbers ?? false,
+ });
+ updateExtension(addExtensionsFor(!editorAppearance.preventLineNumbers, adaptedLineNumbers()), preventLineNumbersCompartment.current)
+ }, [preventLineNumbers, editorAppearance.preventLineNumbers])
React.useEffect(() => {
- updateExtension(addExtensionsFor(!preventLineNumbers, adaptedLineNumbers()), preventLineNumbersCompartment.current)
- }, [preventLineNumbers])
+ setEditorAppearance({
+ ...editorAppearance,
+ wrapLines: wrapLines ?? editorAppearance?.wrapLines ?? false,
+ });
+ updateExtension(addExtensionsFor(editorAppearance.wrapLines!, EditorView?.lineWrapping), wrapLinesCompartment.current)
+ }, [wrapLines, editorAppearance.wrapLines])
React.useEffect(() => {
- updateExtension(addExtensionsFor(shouldHighlightActiveLine ?? false, adaptedHighlightActiveLine()), shouldHighlightActiveLineCompartment.current)
- }, [shouldHighlightActiveLine])
+ updateExtension(addExtensionsFor(shouldHaveMinimalSetup ?? true, minimalSetup), shouldHaveMinimalSetupCompartment.current)
+ }, [shouldHaveMinimalSetup])
React.useEffect(() => {
- updateExtension(addExtensionsFor(wrapLines ?? false, EditorView?.lineWrapping), wrapLinesCompartment.current)
- }, [wrapLines])
+ updateExtension(addExtensionsFor(shouldHighlightActiveLine ?? false, adaptedHighlightActiveLine()), shouldHighlightActiveLineCompartment.current)
+ }, [shouldHighlightActiveLine])
React.useEffect(() => {
updateExtension(addExtensionsFor(supportCodeFolding ?? false, adaptedFoldGutter(), adaptedCodeFolding()), supportCodeFoldingCompartment.current)
@@ -485,6 +519,17 @@ export const CodeEditor = ({
translate={getTranslation}
disabled={disabled}
readonly={readOnly}
+ configMenu={(
+
+ )}
/>
{showPreview && (
diff --git a/src/extensions/codemirror/tests/CodeEditor.test.tsx b/src/extensions/codemirror/tests/CodeEditor.test.tsx
new file mode 100644
index 000000000..20e4507be
--- /dev/null
+++ b/src/extensions/codemirror/tests/CodeEditor.test.tsx
@@ -0,0 +1,138 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import "@testing-library/jest-dom";
+
+import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
+import { CodeEditor } from "../CodeMirror";
+
+const contextOverlayClass = `${eccgui}-contextoverlay`;
+
+const setupDocumentRange = () => {
+ document.createRange = () => {
+ const range = new Range();
+ range.getBoundingClientRect = jest.fn();
+ range.getClientRects = () => ({
+ item: () => null,
+ length: 0,
+ [Symbol.iterator]: jest.fn(),
+ });
+ return range;
+ };
+};
+
+describe("CodeEditor - markdown mode with toolbar", () => {
+ beforeAll(() => {
+ setupDocumentRange();
+ });
+
+ // The toolbar contains a Paragraphs ContextMenu first, then the EditorAppearanceConfigMenu last.
+ const getConfigMenuOverlay = (container: HTMLElement) => {
+ const overlays = container.getElementsByClassName(contextOverlayClass);
+ return overlays[overlays.length - 1] as HTMLElement;
+ };
+
+ it("renders toolbar when mode is markdown and useToolbar is true", () => {
+ const { container } = render();
+ expect(container.querySelector(`.${eccgui}-codeeditor__toolbar`)).not.toBeNull();
+ });
+
+ it("does not render toolbar when useToolbar is false", () => {
+ const { container } = render();
+ expect(container.querySelector(`.${eccgui}-codeeditor__toolbar`)).toBeNull();
+ });
+
+ it("does not render toolbar for non-markdown modes even when useToolbar is true", () => {
+ const { container } = render();
+ expect(container.querySelector(`.${eccgui}-codeeditor__toolbar`)).toBeNull();
+ });
+
+ it("includes the EditorAppearanceConfigMenu in the markdown toolbar", () => {
+ const { container } = render();
+ const toolbar = container.querySelector(`.${eccgui}-codeeditor__toolbar`);
+ // Toolbar contains at least the Paragraphs menu and the EditorAppearanceConfigMenu
+ expect(toolbar?.getElementsByClassName(contextOverlayClass).length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("defaults wrapLines to true in markdown mode with toolbar", async () => {
+ const { container } = render();
+
+ fireEvent.click(getConfigMenuOverlay(container));
+
+ const wrapLinesItem = await screen.findByText("wrapLines");
+ expect(wrapLinesItem.closest("[aria-selected='true']")).not.toBeNull();
+ });
+
+ it("defaults preventLineNumbers to true in markdown mode with toolbar", async () => {
+ const { container } = render();
+
+ fireEvent.click(getConfigMenuOverlay(container));
+
+ const preventLineNumbersItem = await screen.findByText("preventLineNumbers");
+ expect(preventLineNumbersItem.closest("[aria-selected='true']")).not.toBeNull();
+ });
+
+ it("locks wrapLines in config menu when wrapLines prop is explicitly provided", async () => {
+ const { container } = render(
+
+ );
+
+ fireEvent.click(getConfigMenuOverlay(container));
+
+ const wrapLinesItem = await screen.findByText("wrapLines");
+ expect(wrapLinesItem.closest("[aria-disabled='true']")).not.toBeNull();
+ });
+
+ it("locks preventLineNumbers in config menu when preventLineNumbers prop is explicitly provided", async () => {
+ const { container } = render(
+
+ );
+
+ fireEvent.click(getConfigMenuOverlay(container));
+
+ const preventLineNumbersItem = await screen.findByText("preventLineNumbers");
+ expect(preventLineNumbersItem.closest("[aria-disabled='true']")).not.toBeNull();
+ });
+
+ it("does not lock wrapLines in config menu when wrapLines prop is not provided", async () => {
+ const { container } = render();
+
+ fireEvent.click(getConfigMenuOverlay(container));
+
+ const wrapLinesItem = await screen.findByText("wrapLines");
+ expect(wrapLinesItem.closest("[aria-disabled='true']")).toBeNull();
+ });
+
+ it("does not lock preventLineNumbers in config menu when preventLineNumbers prop is not provided", async () => {
+ const { container } = render();
+
+ fireEvent.click(getConfigMenuOverlay(container));
+
+ const preventLineNumbersItem = await screen.findByText("preventLineNumbers");
+ expect(preventLineNumbersItem.closest("[aria-disabled='true']")).toBeNull();
+ });
+
+ it("disables config menu trigger when both wrapLines and preventLineNumbers props are provided", () => {
+ const { container } = render(
+
+ );
+
+ const configMenuTrigger = getConfigMenuOverlay(container).querySelector("button");
+ expect(configMenuTrigger).toBeDisabled();
+ });
+
+ it("disables config menu trigger when editor is disabled", () => {
+ const { container } = render(
+
+ );
+
+ const configMenuTrigger = getConfigMenuOverlay(container).querySelector("button");
+ expect(configMenuTrigger).toBeDisabled();
+ });
+});
diff --git a/src/extensions/codemirror/tests/EditorAppearanceConfigMenu.test.tsx b/src/extensions/codemirror/tests/EditorAppearanceConfigMenu.test.tsx
new file mode 100644
index 000000000..deb1b6370
--- /dev/null
+++ b/src/extensions/codemirror/tests/EditorAppearanceConfigMenu.test.tsx
@@ -0,0 +1,131 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import "@testing-library/jest-dom";
+
+import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
+import { EditorAppearanceConfigMenu } from "../toolbars/EditorAppearanceConfigMenu";
+
+const contextOverlayClass = `${eccgui}-contextoverlay`;
+
+describe("EditorAppearanceConfigMenu", () => {
+ it("renders menu items for each config property, using key as fallback label", async () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const setConfig = jest.fn();
+
+ const { container } = render();
+
+ fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
+
+ expect(await screen.findByText("wrapLines")).toBeVisible();
+ expect(await screen.findByText("preventLineNumbers")).toBeVisible();
+ });
+
+ it("uses configPropertyTranslate for menu item labels", async () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const setConfig = jest.fn();
+ const translate = (key: string) => `Label_${key}` as string | false;
+
+ const { container } = render(
+
+ );
+
+ fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
+
+ expect(await screen.findByText("Label_wrapLines")).toBeVisible();
+ expect(await screen.findByText("Label_preventLineNumbers")).toBeVisible();
+ });
+
+ it("calls setConfig with the toggled value when a menu item is clicked", async () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const setConfig = jest.fn();
+
+ const { container } = render();
+
+ fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
+ const wrapLinesItem = await screen.findByText("wrapLines");
+ fireEvent.click(wrapLinesItem);
+
+ expect(setConfig).toHaveBeenCalledWith({ wrapLines: false, preventLineNumbers: false });
+ });
+
+ it("menu trigger is disabled when all config properties are locked", () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const configLocked = { wrapLines: true, preventLineNumbers: true };
+ const setConfig = jest.fn();
+
+ const { container } = render(
+
+ );
+
+ const trigger = container.getElementsByClassName(contextOverlayClass)[0].querySelector("button");
+ expect(trigger).toBeDisabled();
+ });
+
+ it("menu trigger is enabled when not all config properties are locked", () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const configLocked = { wrapLines: true }; // only one locked
+ const setConfig = jest.fn();
+
+ const { container } = render(
+
+ );
+
+ const trigger = container.getElementsByClassName(contextOverlayClass)[0].querySelector("button");
+ expect(trigger).not.toBeDisabled();
+ });
+
+ it("locked config property has a disabled menu item", async () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const configLocked = { wrapLines: true };
+ const setConfig = jest.fn();
+
+ const { container } = render(
+
+ );
+
+ fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
+
+ const wrapLinesItem = await screen.findByText("wrapLines");
+ expect(wrapLinesItem.closest("[aria-disabled='true']")).not.toBeNull();
+ });
+
+ it("unlocked config property has an enabled menu item", async () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const configLocked = { wrapLines: true }; // only wrapLines is locked
+ const setConfig = jest.fn();
+
+ const { container } = render(
+
+ );
+
+ fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
+
+ const preventLineNumbersItem = await screen.findByText("preventLineNumbers");
+ expect(preventLineNumbersItem.closest("[aria-disabled='true']")).toBeNull();
+ });
+
+ it("selected config property is marked as selected in the menu", async () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const setConfig = jest.fn();
+
+ const { container } = render();
+
+ fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
+
+ const wrapLinesItem = await screen.findByText("wrapLines");
+ expect(wrapLinesItem.closest("[aria-selected='true']")).not.toBeNull();
+ });
+
+ it("unselected config property is not marked as selected in the menu", async () => {
+ const config = { wrapLines: true, preventLineNumbers: false };
+ const setConfig = jest.fn();
+
+ const { container } = render();
+
+ fireEvent.click(container.getElementsByClassName(contextOverlayClass)[0]);
+
+ const preventLineNumbersItem = await screen.findByText("preventLineNumbers");
+ expect(preventLineNumbersItem.closest("[aria-selected='true']")).toBeNull();
+ });
+});
diff --git a/src/extensions/codemirror/toolbars/EditorAppearanceConfigMenu.tsx b/src/extensions/codemirror/toolbars/EditorAppearanceConfigMenu.tsx
new file mode 100644
index 000000000..413242a33
--- /dev/null
+++ b/src/extensions/codemirror/toolbars/EditorAppearanceConfigMenu.tsx
@@ -0,0 +1,59 @@
+import React from "react";
+
+import { ContextMenu, ContextMenuProps, MenuItem } from "../../../components";
+
+export interface EditorAppearanceConfigMenuProps {
+ /** Object containing a `true`/`false` value for each property */
+ config: { [s: string]: boolean };
+ /** Object containing `true` for each property that cannot be changed by user */
+ configLocked?: { [s: string]: boolean | undefined };
+ /** Handler that returns a translation for each config property key */
+ configPropertyTranslate?: (key: string) => string | false;
+ /** Handler to update config after user changes */
+ setConfig: React.Dispatch>;
+ /** Additional properties used for the included `ContextMenu` */
+ contextMenuProps?: ContextMenuProps;
+}
+
+/**
+ * Returns a simple context menu that provides switches to control the editor appearance.
+ */
+export function EditorAppearanceConfigMenu({
+ config,
+ configLocked = {},
+ configPropertyTranslate = (s) => s,
+ setConfig,
+ contextMenuProps,
+}: EditorAppearanceConfigMenuProps) {
+ return (
+ {
+ return typeof value !== "undefined";
+ }).length
+ }
+ >
+ {Object.entries(config).map(([key, value]) => {
+ return (
+
+ );
+}
diff --git a/src/extensions/codemirror/toolbars/markdown.toolbar.tsx b/src/extensions/codemirror/toolbars/markdown.toolbar.tsx
index 13a1444b6..b82534584 100644
--- a/src/extensions/codemirror/toolbars/markdown.toolbar.tsx
+++ b/src/extensions/codemirror/toolbars/markdown.toolbar.tsx
@@ -9,6 +9,7 @@ import { Spacing } from "../../../components/Separation/Spacing";
import { Toolbar, ToolbarSection } from "../../../components/Toolbar";
import MarkdownCommand from "./commands/markdown.command";
+import { EditorAppearanceConfigMenu } from "./EditorAppearanceConfigMenu";
interface MarkdownToolbarProps {
view?: EditorView;
@@ -17,6 +18,7 @@ interface MarkdownToolbarProps {
translate: (key: string) => string | false;
disabled?: boolean;
readonly?: boolean;
+ configMenu?: React.ReactElement;
}
export const MarkdownToolbar: React.FC = ({
@@ -25,7 +27,8 @@ export const MarkdownToolbar: React.FC = ({
showPreview,
disabled,
readonly,
- translate
+ translate,
+ configMenu,
}) => {
const commandRef = React.useRef(null);
@@ -35,10 +38,10 @@ export const MarkdownToolbar: React.FC = ({
}
}, [view]);
- const getTranslation = (fallback: string) : string => {
+ const getTranslation = (fallback: string): string => {
const key = fallback.toLowerCase().replace(" ", "-");
return translate(key) || fallback;
- }
+ };
const { basic, lists, attachments } = MarkdownCommand.commands;
return (
@@ -112,6 +115,17 @@ export const MarkdownToolbar: React.FC = ({
disabled={disabled}
/>
+ {configMenu && (
+
+
+ {React.cloneElement(configMenu, {
+ ...{
+ ...configMenu.props,
+ contextMenuProps: { disabled: showPreview || disabled ? true : undefined },
+ },
+ })}
+
+ )}
);
};