diff --git a/src/components/text-editor/highlight-color-menu/editor-highlight-color-menu.scss b/src/components/text-editor/highlight-color-menu/editor-highlight-color-menu.scss new file mode 100644 index 0000000000..e22c8e5540 --- /dev/null +++ b/src/components/text-editor/highlight-color-menu/editor-highlight-color-menu.scss @@ -0,0 +1,36 @@ +:host(limel-text-editor-highlight-color-menu) { + animation: fade 0.2s ease forwards; + animation-delay: 0.1s; // prevents the visual glitch when the menu opens + opacity: 0; + + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + max-width: calc(100vw - 2rem); + border-radius: 0.5rem; + background-color: var(--lime-elevated-surface-background-color); + box-shadow: var(--shadow-depth-16); +} + +.color-picker-container { + // Ensure the color picker has proper space + min-height: 3rem; +} + +.actions { + display: flex; + justify-content: end; + gap: 0.5rem; +} + +@keyframes fade { + 0% { + scale: 0.86; + opacity: 0; + } + 100% { + scale: 1; + opacity: 1; + } +} diff --git a/src/components/text-editor/highlight-color-menu/editor-highlight-color-menu.tsx b/src/components/text-editor/highlight-color-menu/editor-highlight-color-menu.tsx new file mode 100644 index 0000000000..9dc5614be5 --- /dev/null +++ b/src/components/text-editor/highlight-color-menu/editor-highlight-color-menu.tsx @@ -0,0 +1,152 @@ +import { Component, Prop, h, Event, EventEmitter } from '@stencil/core'; +import { Languages } from '../../date-picker/date.types'; +import translate from '../../../global/translations'; +import { ENTER, ESCAPE } from '../../../util/keycodes'; + +/** + * This component is a menu for selecting highlight color in the text editor. + * It allows the user to choose a color for text highlighting. + * @beta + * @private + */ +@Component({ + tag: 'limel-text-editor-highlight-color-menu', + shadow: true, + styleUrl: 'editor-highlight-color-menu.scss', +}) +export class TextEditorHighlightColorMenu { + /** + * The selected color + */ + @Prop({ reflect: true }) + public color: string = 'rgb(var(--color-yellow-light))'; + + /** + * Defines the language for translations. + */ + @Prop({ reflect: true }) + public language: Languages = 'en'; + + /** + * Open state of the highlight-color-menu dialog + */ + @Prop({ reflect: true }) + public isOpen: boolean = false; + + /** + * Emitted when the menu is closed from inside the component. + * (*Not* emitted when the consumer sets the `open`-property to `false`.) + */ + @Event() + private cancel: EventEmitter; + + /** + * Emitted when the menu is saved from inside the component. + */ + @Event() + private save: EventEmitter; + + /** + * Emitted when the user selects a new color + */ + @Event() + private colorChange: EventEmitter; + + private colorPicker: HTMLLimelColorPickerElement; + private saveButton: HTMLLimelButtonElement; + + public connectedCallback() { + this.setupGlobalHandlers(); + } + + public disconnectedCallback() { + this.teardownGlobalHandlers(); + } + + public componentDidRender() { + this.focusOnColorPicker(); + } + + private setupGlobalHandlers() { + document.addEventListener('keydown', this.handleKeyDown); + } + + private teardownGlobalHandlers() { + document.removeEventListener('keydown', this.handleKeyDown); + } + + private focusOnColorPicker() { + if (this.isOpen && this.colorPicker) { + // Focus the color picker when the menu opens + setTimeout(() => { + this.colorPicker?.focus(); + }, 100); + } + } + + public render() { + return [ +
+ + (this.colorPicker = el as HTMLLimelColorPickerElement) + } + /> +
, +
+ + + (this.saveButton = el as HTMLLimelButtonElement) + } + slot="button" + /> +
, + ]; + } + + private getTranslation = (key: string) => { + return translate.get(key, this.language); + }; + + private handleKeyDown = (event: KeyboardEvent) => { + if (!this.isOpen) { + return; + } + + if (event.key === ESCAPE) { + this.handleCancel(); + } else if (event.key === ENTER) { + this.handleSave(); + } + }; + + private handleColorChange = (event: CustomEvent) => { + this.colorChange.emit(event.detail); + }; + + private handleCancel = () => { + this.cancel.emit(); + }; + + private handleSave = () => { + this.save.emit(); + }; +} diff --git a/src/components/text-editor/highlight-color-menu/examples/highlight-color-menu.tsx b/src/components/text-editor/highlight-color-menu/examples/highlight-color-menu.tsx new file mode 100644 index 0000000000..6e0696578c --- /dev/null +++ b/src/components/text-editor/highlight-color-menu/examples/highlight-color-menu.tsx @@ -0,0 +1,57 @@ +import { Component, h, State } from '@stencil/core'; + +/** + * Example of the highlight color menu component + */ +@Component({ + tag: 'limel-example-highlight-color-menu', + shadow: true, +}) +export class HighlightColorMenuExample { + @State() + private color: string = 'rgb(var(--color-yellow-light))'; + + @State() + private isOpen: boolean = false; + + public render() { + return [ +
+ +

Selected color: {this.color}

+

+ Instructions: Click the colored square + button to open the color palette with 100 visual color + swatches! +

+
, + , + ]; + } + + private openMenu = () => { + this.isOpen = true; + }; + + private handleColorChange = (event: CustomEvent) => { + this.color = event.detail; + }; + + private handleCancel = () => { + this.isOpen = false; + }; + + private handleSave = () => { + this.isOpen = false; + console.log('Selected highlight color:', this.color); + }; +} diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts index 8c79a826c2..1bf6fb989f 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts @@ -8,7 +8,8 @@ import { getLinkAttributes } from '../plugins/link/utils'; type CommandFunction = ( schema: Schema, mark: EditorMenuTypes, - link?: EditorTextLink + link?: EditorTextLink, + color?: string ) => CommandWithActive; interface CommandMapping { @@ -110,7 +111,8 @@ const createInsertLinkCommand: CommandFunction = ( const createToggleMarkCommand = ( schema: Schema, markName: string, - link?: EditorTextLink + link?: EditorTextLink, + _color?: string ): CommandWithActive => { const markType: MarkType | undefined = schema.marks[markName]; if (!markType) { @@ -125,6 +127,25 @@ const createToggleMarkCommand = ( return command; }; +const createToggleHighlightCommand = ( + schema: Schema, + markName: string, + _link?: EditorTextLink, + color?: string +): CommandWithActive => { + const markType: MarkType | undefined = schema.marks[markName]; + if (!markType) { + throw new Error(`Mark "${markName}" not found in schema`); + } + + const attrs = color ? { color } : {}; + + const command: CommandWithActive = toggleMark(markType, attrs); + setActiveMethodForMark(command, markType); + + return command; +}; + const getAttributes = ( markName: string, link: EditorTextLink @@ -301,6 +322,7 @@ const commandMapping: CommandMapping = { underline: createToggleMarkCommand, strikethrough: createToggleMarkCommand, code: createToggleMarkCommand, + highlight: createToggleHighlightCommand, link: createInsertLinkCommand, headerlevel1: (schema) => createSetNodeTypeCommand( @@ -338,12 +360,25 @@ export class MenuCommandFactory { this.schema = schema; } - public getCommand(mark: EditorMenuTypes, link?: EditorTextLink) { + public getCommand( + mark: EditorMenuTypes, + link?: EditorTextLink, + color?: string + ) { const commandFunc = commandMapping[mark]; if (!commandFunc) { throw new Error(`The Mark "${mark}" is not supported`); } + if (mark === EditorMenuTypes.Highlight && color) { + return createToggleHighlightCommand( + this.schema, + mark, + undefined, + color + ); + } + return commandFunc(this.schema, mark, link); } @@ -355,6 +390,7 @@ export class MenuCommandFactory { 'Mod-Shift-2': this.getCommand(EditorMenuTypes.HeaderLevel2), 'Mod-Shift-3': this.getCommand(EditorMenuTypes.HeaderLevel3), 'Mod-Shift-X': this.getCommand(EditorMenuTypes.Strikethrough), + 'Mod-Shift-H': this.getCommand(EditorMenuTypes.Highlight), 'Mod-`': this.getCommand(EditorMenuTypes.Code), 'Mod-Shift-C': this.getCommand(EditorMenuTypes.CodeBlock), }; diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts index ed4dceb346..0c826eddf4 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-items.ts @@ -45,6 +45,14 @@ const textEditorMenuItems: Array< iconOnly: true, selected: false, }, + { + value: EditorMenuTypes.Highlight, + text: 'Highlight', + commandText: `${mod} ${shift} H`, + icon: 'marker_pen', + iconOnly: true, + selected: false, + }, { value: EditorMenuTypes.Link, text: 'Link', @@ -135,6 +143,7 @@ export const menuTranslationIDs = { link: 'editor-menu.link', strikethrough: 'editor-menu.strikethrough', code: 'editor-menu.code', + highlight: 'editor-menu.highlight', }; export type menuTranslationIDs = diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-schema-extender.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-schema-extender.ts index de1c3eb2f8..a9f9a0b62a 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/menu-schema-extender.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-schema-extender.ts @@ -11,3 +11,37 @@ export const strikethrough: MarkSpec = { return ['s', 0]; }, }; + +export const highlight: MarkSpec = { + attrs: { + color: { default: 'rgb(var(--color-yellow-light))' }, + }, + parseDOM: [ + { + tag: 'mark', + getAttrs: (dom) => ({ + color: + (dom as HTMLElement).style.backgroundColor || + 'rgb(var(--color-yellow-light))', + }), + }, + { + tag: 'span[style*="background-color"]', + getAttrs: (dom) => ({ + color: + (dom as HTMLElement).style.backgroundColor || + 'rgb(var(--color-yellow-light))', + }), + }, + ], + toDOM: (node) => { + return [ + 'mark', + { + class: 'lime-text-highlight', + style: `background-color: ${node.attrs.color}`, + }, + 0, + ]; + }, +}; diff --git a/src/components/text-editor/prosemirror-adapter/menu/types.ts b/src/components/text-editor/prosemirror-adapter/menu/types.ts index 8de1093fd8..9068d70c38 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/types.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/types.ts @@ -18,6 +18,7 @@ export const EditorMenuTypes = { Strikethrough: 'strikethrough', Code: 'code', CodeBlock: 'code_block', + Highlight: 'highlight', }; export type EditorMenuTypes = diff --git a/src/components/text-editor/prosemirror-adapter/plugins/menu-action-interaction-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/menu-action-interaction-plugin.ts index bbdd304b7a..72beb4fe2b 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/menu-action-interaction-plugin.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/menu-action-interaction-plugin.ts @@ -59,6 +59,26 @@ export const createActionBarInteractionPlugin = ( } } + return true; + }, + saveHighlightMenu: (view, event) => { + event.preventDefault(); + event.stopPropagation(); + const { type, color } = event.detail; + + if (type === EditorMenuTypes.Highlight) { + try { + const command = menuCommandFactory.getCommand( + type, + undefined, + color + ); + dispatchMenuCommand(command, view); + } catch (error) { + console.error(`Error executing command: ${error}`); + } + } + return true; }, }, diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss index 8694219e73..cdbf062aff 100644 --- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss +++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.scss @@ -169,3 +169,9 @@ img.ProseMirror-separator { limel-portal { width: 25rem; } + +.lime-text-highlight { + padding: 0.125em 0.25em; + border-radius: 0.25em; + /* background-color will be set dynamically via style attribute */ +} diff --git a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx index d7facb46f6..bcb2fb4f94 100644 --- a/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx +++ b/src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx @@ -33,7 +33,7 @@ import { createRandomString } from '../../../util/random-string'; import { isItem } from '../../action-bar/is-item'; import { cloneDeep, debounce } from 'lodash-es'; import { Languages } from '../../date-picker/date.types'; -import { strikethrough } from './menu/menu-schema-extender'; +import { strikethrough, highlight } from './menu/menu-schema-extender'; import { createLinkPlugin } from './plugins/link/link-plugin'; import { linkMarkSpec } from './plugins/link/link-mark-spec'; import { createImageInserterPlugin } from './plugins/image/inserter'; @@ -150,6 +150,12 @@ export class ProsemirrorAdapter { @State() public isLinkMenuOpen: boolean = false; + @State() + private isHighlightColorMenuOpen = false; + + @State() + private highlightColor = 'rgb(var(--color-yellow-light))'; + private menuCommandFactory: MenuCommandFactory; private schema: Schema; private contentConverter: ContentTypeConverter; @@ -270,6 +276,7 @@ export class ProsemirrorAdapter {
{this.renderToolbar()} {this.renderLinkMenu()} + {this.renderHighlightColorMenu()} ); } @@ -315,6 +322,30 @@ export class ProsemirrorAdapter { ); } + renderHighlightColorMenu() { + if (!this.isHighlightColorMenuOpen) { + return; + } + + return ( + + + + ); + } + private setupContentConverter() { if (this.contentType === 'markdown') { this.contentConverter = new MarkdownConverter( @@ -392,6 +423,7 @@ export class ProsemirrorAdapter { marks: schema.spec.marks.append({ strikethrough: strikethrough, link: linkMarkSpec, + highlight: highlight, }), }); } @@ -540,6 +572,12 @@ export class ProsemirrorAdapter { return; } + if (value === EditorMenuTypes.Highlight) { + this.isHighlightColorMenuOpen = true; + + return; + } + const actionBarEvent = new CustomEvent('actionBarItemClick', { detail: event.detail, }); @@ -572,6 +610,29 @@ export class ProsemirrorAdapter { this.link = event.detail; }; + private handleCancelHighlightColorMenu = (event: CustomEvent) => { + event.preventDefault(); + event.stopPropagation(); + + this.isHighlightColorMenuOpen = false; + }; + + private handleSaveHighlightColorMenu = () => { + this.isHighlightColorMenuOpen = false; + + const saveHighlightEvent = new CustomEvent('saveHighlightMenu', { + detail: { + type: EditorMenuTypes.Highlight, + color: this.highlightColor, + }, + }); + this.view.dom.dispatchEvent(saveHighlightEvent); + }; + + private handleHighlightColorChange = (event: CustomEvent) => { + this.highlightColor = event.detail; + }; + private handleFocus = () => { if (!this.disabled) { this.view?.focus(); diff --git a/src/components/text-editor/utils/markdown-converter.ts b/src/components/text-editor/utils/markdown-converter.ts index f27ddaa06a..6a27c7486a 100644 --- a/src/components/text-editor/utils/markdown-converter.ts +++ b/src/components/text-editor/utils/markdown-converter.ts @@ -61,6 +61,12 @@ const buildMarkdownSerializer = ( mixable: true, expelEnclosingWhitespace: true, }, + highlight: { + open: '==', + close: '==', + mixable: true, + expelEnclosingWhitespace: true, + }, }; return new MarkdownSerializer(nodes, marks);