diff --git a/demo/src/components/Playground.tsx b/demo/src/components/Playground.tsx index 9fec366ed..b29630fc5 100644 --- a/demo/src/components/Playground.tsx +++ b/demo/src/components/Playground.tsx @@ -266,6 +266,7 @@ export const Playground = memo((props) => { table_ignoreSplittersInBlockMath: true, table_ignoreSplittersInInlineCode: true, table_ignoreSplittersInInlineMath: true, + cellBackground: true, }, ...wysiwygConfig?.extensionOptions, }, diff --git a/demo/src/defaults/content.ts b/demo/src/defaults/content.ts index 1c7011d28..47d5fd849 100644 --- a/demo/src/defaults/content.ts +++ b/demo/src/defaults/content.ts @@ -1,169 +1,6 @@ export const markup = `   -Welcome to the editor! Start typing the character \`/\` - -![Markdown Editor](https://github.com/user-attachments/assets/0b4e5f65-54cf-475f-9c68-557a4e9edb46 =700x) - -## Markdown WYSIWYG and markup editor - -MarkdownEditor is a powerful tool for working with Markdown, which combines WYSIWYG and Markup modes. This means that you can create and edit content in a convenient visual mode, as well as have full control over the markup. - -The editor supports following formats: - -* WYSIWYG - -* markup - -Click on the gear in the upper right corner to change the mode and see the \`md\` markup. - -### Various blocks included - -{% cut "Combine different blocks" %} - -{% note info "Block for notes, tips, warnings, and alerts" %} - -Depending on the content, notes with different titles and formats are used: - -* Note: provides additional information. -* Tip: offers a recommendation. -* Warning: issues a warning. -* Alert: indicates a restriction. - -{% endnote %} - -> [Improve](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-preview.md) the editor interface -> -> *improved by you* - -{% endcut %} - -Or write your extension using a [convenient api](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-create-extension.md) - -### A user-friendly API is provided - -Easily connect to your React app with a hook: - -\`\`\`plaintext -import React from 'react'; -import { useMarkdownEditor, MarkdownEditorView } from '@gravity-ui/markdown-editor'; -import { toaster } from '@gravity-ui/uikit/toaster-singleton'; - -function Editor({ onSubmit }) { - const editor = useMarkdownEditor({ allowHTML: false }); - - React.useEffect(() => { - function submitHandler() { - // Serialize current content to markdown markup - const value = editor.getValue(); - onSubmit(value); - } - - editor.on('submit', submitHandler); - return () => { - editor.off('submit', submitHandler); - }; - }, [onSubmit]); - - return ; -} -\`\`\` - -### Convenient UX control is equipped - -#### Hot keys - -{% list tabs %} - -- WYSIWYG mode - - - - |Formatting|Windows Shortcut|Mac OS Shortcut| - |:---|:---|:---| - |Bold text|Ctrl \\+ B|⌘ \\+ B| - |Italic|Ctrl \\+ I|⌘ \\+ I| - |Underlined text|Ctrl \\+ U|⌘ \\+ U| - |Strikethrough text|Ctrl \\+ Shift \\+ S|⌘ \\+ Shift \\+ S| - -- Markup mode - - - - |Formatting|Markup|Result| - |:---|:---|:---| - |Bold text|\`**Bold**\`|**Bold**| - |Italic|\`*Italic*\`|*Italic*| - |Underlined text|\`++Underlined++\`|++Underlined++| - |Strikethrough text|\`~~Strikethrough~~\`|~~Strikethrough~~| - -{% endlist %} - -#### Context menu - -Select this text and you will see **a context menu**. - -#### Auto-conversion - -Quickly create blocks by entering characters that will be replaced by blocks. For example, the automatic conversion of \`-\` and space creates a list, \`>\` and space creates a quote. Try it out. - ---- - -### Current and future features - -[X] Some already finished things - -[ ] VS Code plugin - -[ ] Mobile version - -### And a multitude of other functionalities :sweat_smile: :fire: - -See - -# More examples {#anchor} - -{% cut "Headings" %} - -# Heading 1 - -## Heading 2 - -### Heading 3 - -#### Heading 4 - -##### Heading 5 - -###### Heading 6 - -{% endcut %} - -{% cut "This is a cut heading" %} - -**A** *here* ~~it~~ ++is awesome++ ^c^~o~^n^~t~^e^~n~^t^ - -> Done deal - deal done \`(quote)\` - -{% endcut %} - -{% cut "Formulas" %} - -This is an inline formula: $\\sqrt{3x-1}+(1+x)^2$ - -And here is a block formula: - -$$f(\\relax{x}) = \\int_{-\\infty}^\\infty - \\hat f(\\xi)\\,e^{2 \\pi i \\xi x} - \\,d\\xi -$$ - -*Click on the formula to edit it* - -{% endcut %} - ---- - #| || @@ -247,34 +84,6 @@ New approaches to learning in the digital age || |# ---- - -{% note info "Attention, please!" %} - -* Thank - - 1. you - - 2. for - - 1. your - - 3. attention - -* (nested lists) - -> > > Quotes -> > -> > Nested -> -> As well - -And ##monospace## can be **##com##**##bined\\*## - -{% endnote %} - ---- - `.trim(); export const loremIpsum = ` diff --git a/demo/src/hocs/withLang.tsx b/demo/src/hocs/withLang.tsx index 4fab8d240..ea343c0d1 100644 --- a/demo/src/hocs/withLang.tsx +++ b/demo/src/hocs/withLang.tsx @@ -3,6 +3,7 @@ import {configure} from '@gravity-ui/uikit'; import type {Decorator} from '@storybook/react'; import '@gravity-ui/uikit/styles/styles.scss'; +import '@gravity-ui/markdown-editor/styles/markdown.css'; // eslint-disable-line import/order export const withLang: Decorator = (StoryItem, context) => { const lang = context.globals.lang; diff --git a/demo/src/hocs/withThemeProvider.tsx b/demo/src/hocs/withThemeProvider.tsx index 48c010ea1..afda74626 100644 --- a/demo/src/hocs/withThemeProvider.tsx +++ b/demo/src/hocs/withThemeProvider.tsx @@ -2,6 +2,7 @@ import {ThemeProvider} from '@gravity-ui/uikit'; import type {Decorator} from '@storybook/react'; import '@gravity-ui/uikit/styles/styles.scss'; +import '@gravity-ui/markdown-editor/styles/markdown.css'; // eslint-disable-line import/order export const withThemeProvider: Decorator = (StoryItem, context) => { return ( diff --git a/demo/src/hocs/withToaster.tsx b/demo/src/hocs/withToaster.tsx index fa7bb4055..40948f5cc 100644 --- a/demo/src/hocs/withToaster.tsx +++ b/demo/src/hocs/withToaster.tsx @@ -3,6 +3,7 @@ import {toaster} from '@gravity-ui/uikit/toaster-singleton'; import type {Decorator} from '@storybook/react'; import '@gravity-ui/uikit/styles/styles.scss'; +import '@gravity-ui/markdown-editor/styles/markdown.css'; // eslint-disable-line import/order export const withToaster: Decorator = (StoryItem, context) => { return ( diff --git a/demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.stories.tsx b/demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.stories.tsx index 7efabb63d..2d64ecb67 100644 --- a/demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.stories.tsx +++ b/demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.stories.tsx @@ -7,6 +7,7 @@ export const Story: StoryObj = { mobile: false, dnd: true, headerRows: true, + cellBackground: true, }, }; Story.storyName = "YFM Table D'n'D"; diff --git a/demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.tsx b/demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.tsx index 1df7cbbd8..94c4b0e4c 100644 --- a/demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.tsx +++ b/demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.tsx @@ -10,12 +10,14 @@ export type YfmTableDnDDemoProps = { mobile: boolean; dnd: boolean; headerRows: boolean; + cellBackground: boolean; }; export const YfmTableDnDDemo = memo(function YfmTableDnDDemo({ mobile, dnd, headerRows, + cellBackground, }) { const editor = useMarkdownEditor( { @@ -32,11 +34,12 @@ export const YfmTableDnDDemo = memo(function YfmTableDnDDe yfmTable: { dnd, headerRows, + cellBackground, }, }, }, }, - [mobile, dnd, headerRows], + [mobile, dnd, headerRows, cellBackground], ); return ( diff --git a/packages/editor/src/extensions/yfm/YfmTable/YfmTable.test.ts b/packages/editor/src/extensions/yfm/YfmTable/YfmTable.test.ts index 5274c0c6f..c584117ee 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/YfmTable.test.ts +++ b/packages/editor/src/extensions/yfm/YfmTable/YfmTable.test.ts @@ -544,7 +544,7 @@ nested table || |# - + `; same( @@ -594,7 +594,7 @@ nested table || |# - + `; same( @@ -642,4 +642,86 @@ cell11 ), ); }); + + describe('cell-bg serialization', () => { + it('should serialize cell-bg on first cell (same line as ||)', () => { + const markup = dd` + #| + ||::{bg="info"} + + cell11 + + || + |# + + + `.trimStart(); + + same(markup, doc(table(tbody(tr(td({[YfmTableAttr.CellBg]: 'info'}, p('cell11'))))))); + }); + + it('should serialize cell-bg on non-first cell (same line as |)', () => { + const markup = dd` + #| + || + + cell11 + + |::{bg="warning"} + + cell12 + + || + |# + + + `.trimStart(); + + same( + markup, + doc( + table( + tbody( + tr( + td(p('cell11')), + td({[YfmTableAttr.CellBg]: 'warning'}, p('cell12')), + ), + ), + ), + ), + ); + }); + + it('should serialize cell-bg on multiple cells', () => { + const markup = dd` + #| + ||::{bg="info"} + + cell11 + + |::{bg="danger"} + + cell12 + + || + |# + + + `.trimStart(); + + same( + markup, + doc( + table( + tbody( + tr( + td({[YfmTableAttr.CellBg]: 'info'}, p('cell11')), + td({[YfmTableAttr.CellBg]: 'danger'}, p('cell12')), + ), + ), + ), + ), + ); + }); + }); }); diff --git a/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/const.ts b/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/const.ts index d6f0819a0..3e74d8504 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/const.ts +++ b/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/const.ts @@ -8,6 +8,7 @@ export enum YfmTableNode { export enum YfmTableAttr { Colspan = 'colspan', Rowspan = 'rowspan', + CellBg = 'data-bg', CellAlign = 'data-cell-align', HeaderRows = 'data-header-rows', } diff --git a/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts b/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts index 13269b8c2..1addf55cb 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts +++ b/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts @@ -92,6 +92,7 @@ export const getSchemaSpecs = ( [YfmTableAttr.Colspan]: {default: null}, [YfmTableAttr.Rowspan]: {default: null}, [YfmTableAttr.CellAlign]: {default: null}, + [YfmTableAttr.CellBg]: {default: null}, }, parseDOM: [ {tag: 'td', priority: 200}, diff --git a/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/serializer.ts b/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/serializer.ts index b95f4a884..e7151e0c0 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/serializer.ts +++ b/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/serializer.ts @@ -27,7 +27,9 @@ export const serializerTokens: Record = { const rowspanStack: Record = {}; tbody.forEach((trow) => { - state.write('||'); + const firstCellBg = trow.firstChild?.attrs[YfmTableAttr.CellBg]; + const firstCellAttrs = typeof firstCellBg === 'string' ? `::{bg="${firstCellBg}"}` : ''; + state.write(`||${firstCellAttrs}`); state.ensureNewLine(); state.write('\n'); @@ -46,7 +48,9 @@ export const serializerTokens: Record = { } if (colIndex > 0) { - state.write('|'); + const cellBg = td.attrs[YfmTableAttr.CellBg]; + const cellAttrs = typeof cellBg === 'string' ? `::{bg="${cellBg}"}` : ''; + state.write(cellAttrs ? `|${cellAttrs}` : '|'); state.ensureNewLine(); state.write('\n'); } diff --git a/packages/editor/src/extensions/yfm/YfmTable/index.ts b/packages/editor/src/extensions/yfm/YfmTable/index.ts index 6a35b1ea2..46f18b595 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/index.ts +++ b/packages/editor/src/extensions/yfm/YfmTable/index.ts @@ -19,6 +19,8 @@ export { yfmTableCellType, } from './YfmTableSpecs'; +// TODO [MAJOR]: enable by default and remove options: headerRows, cellBackground + export type YfmTableOptions = YfmTableSpecsOptions & { /** * Enables floating controls for table. @@ -39,6 +41,14 @@ export type YfmTableOptions = YfmTableSpecsOptions & { * @default false */ headerRows?: boolean; + /** + * Enables cell background color picker for table cells. + * The `controls` property must also be `true`. + * + * Available with @diplodoc/transform v4.75.0 or higher. + * @default false + */ + cellBackground?: boolean; }; export const YfmTable: ExtensionWithOptions = (builder, options) => { @@ -59,6 +69,7 @@ export const YfmTable: ExtensionWithOptions = (builder, options yfmTableControlsPlugins({ dndEnabled: options.dnd !== false, headerRowsEnabled: options.headerRows === true, + cellBackgroundEnabled: options.cellBackground === true, }), ); } diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/set-cell-bg.ts b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/set-cell-bg.ts new file mode 100644 index 000000000..27e3ab92a --- /dev/null +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/set-cell-bg.ts @@ -0,0 +1,49 @@ +import type {Command} from '#pm/state'; +import {TableDesc} from 'src/table-utils/table-desc'; + +import {YfmTableAttr} from '../../../YfmTableSpecs/const'; + +export type SetCellBgParams = { + tablePos: number; + rows?: number[]; + cols?: number[]; + bg: string | null; +}; + +export const setCellBg = (params: SetCellBgParams): Command => { + return (state, dispatch) => { + const table = state.doc.nodeAt(params.tablePos); + const tableDesc = table && TableDesc.create(table)?.bind(params.tablePos); + if (!tableDesc) return false; + + if (!dispatch) return true; + + const {tr} = state; + + const apply = (cellPos: ReturnType[number]) => { + if (cellPos.type !== 'real') return; + const node = state.doc.nodeAt(cellPos.from); + if (!node) return; + tr.setNodeAttribute( + tr.mapping.map(cellPos.from), + YfmTableAttr.CellBg, + params.bg || null, + ); + }; + + if (params.rows) { + for (const rowIdx of params.rows) { + for (const pos of tableDesc.getPosForRowCells(rowIdx)) apply(pos); + } + } + if (params.cols) { + for (const colIdx of params.cols) { + for (const pos of tableDesc.getPosForColumn(colIdx)) apply(pos); + } + } + + if (tr.docChanged) dispatch(tr); + + return true; + }; +}; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgMenuItem/CellBgMenuItem.tsx b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgMenuItem/CellBgMenuItem.tsx new file mode 100644 index 000000000..511a638bf --- /dev/null +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgMenuItem/CellBgMenuItem.tsx @@ -0,0 +1,72 @@ +import {useEffect, useRef} from 'react'; + +import {ChevronRight, BucketPaint as ColorPalette} from '@gravity-ui/icons'; +import {Icon, Menu, Popup, sp} from '@gravity-ui/uikit'; + +import {i18n} from 'src/i18n/yfm-table'; +import {useBooleanState, useElementState} from 'src/react-utils'; + +import {CellBgPalette} from '../CellBgPalette'; + +const CLOSE_DELAY = 120; + +export type CellBgMenuItemProps = { + qa?: string; + currentCellBg?: string | null; + onCellBgChange: (color: string | null) => void; +}; + +export const CellBgMenuItem: React.FC = function YfmTableCellBgMenuItem({ + qa, + currentCellBg, + onCellBgChange, +}) { + const [anchorElement, setAnchorElement] = useElementState(); + const closeTimer = useRef | null>(null); + const [open, openPopup, closePopup] = useBooleanState(false); + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + const scheduleClose = () => { + cancelClose(); + closeTimer.current = setTimeout(closePopup, CLOSE_DELAY); + }; + + useEffect(() => () => cancelClose(), []); + + return ( + } + iconEnd={} + onClick={() => { + cancelClose(); + openPopup(); + }} + extraProps={{ + onMouseEnter: () => { + cancelClose(); + openPopup(); + }, + onMouseLeave: scheduleClose, + }} + > + {i18n('cells.bg')} + +
+ +
+
+
+ ); +}; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgMenuItem/index.ts b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgMenuItem/index.ts new file mode 100644 index 000000000..1ed18093e --- /dev/null +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgMenuItem/index.ts @@ -0,0 +1 @@ +export * from './CellBgMenuItem'; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/CellBgPalette.scss b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/CellBgPalette.scss new file mode 100644 index 000000000..559d96320 --- /dev/null +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/CellBgPalette.scss @@ -0,0 +1,86 @@ +$color-map: ( + 'grey': var(--g-color-base-generic), + 'yellow-light': var(--g-color-base-warning-light), + 'yellow': var(--g-color-base-warning-medium), + 'red-light': var(--g-color-base-danger-light), + 'red': var(--g-color-base-danger-medium), + 'purple-light': var(--g-color-base-utility-light), + 'purple': var(--g-color-base-utility-medium), + 'blue-light': var(--g-color-base-info-light), + 'blue': var(--g-color-base-info-medium), + 'green-light': var(--g-color-base-positive-light), + 'green': var(--g-color-base-positive-medium), +); + +.g-md-yfm-table-cell-bg-palette { + display: grid; + grid-template-columns: repeat(6, auto); + gap: 0; + + &__item { + display: flex; + justify-content: center; + align-items: center; + + font: inherit; + cursor: pointer; + + color: inherit; + border: 0; + border-radius: var(--g-border-radius-m); + background: transparent; + + appearance: none; + + &:hover { + background: var(--g-color-base-generic); + } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 1px solid var(--g-color-line-focus); + outline-offset: -1px; + } + } + + &__swatch { + position: relative; + + display: block; + + width: 16px; + height: 16px; + padding: 0; + + cursor: pointer; + + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-xs); + background-color: transparent; + + @each $name, $color in $color-map { + &_color_#{$name} { + border-color: $color; + background-color: $color; + } + } + + &_none { + background-color: transparent; + } + } + + &__check { + position: absolute; + inset: 0; + + display: flex; + justify-content: center; + align-items: center; + + color: var(--g-color-text-primary); + } +} diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/CellBgPalette.tsx b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/CellBgPalette.tsx new file mode 100644 index 000000000..2ed5574a0 --- /dev/null +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/CellBgPalette.tsx @@ -0,0 +1,78 @@ +import {Check} from '@gravity-ui/icons'; +import {Icon, Tooltip, spacing} from '@gravity-ui/uikit'; + +import {cn} from 'src/classname'; +import {i18n} from 'src/i18n/yfm-table'; + +import {CELL_BG_SWATCHES} from './colors'; + +import './CellBgPalette.scss'; + +const b = cn('yfm-table-cell-bg-palette'); + +const NO_COLOR_VALUE = ''; + +export type CellBgPaletteProps = { + value?: string | null; + onSelect: (color: string | null) => void; +}; + +export const CellBgPalette: React.FC = function YfmTableCellBgPalette({ + value, + onSelect, +}) { + const handleClick = (swatchValue: string) => { + if (swatchValue === value || (swatchValue === NO_COLOR_VALUE && !value)) return; + onSelect(swatchValue === NO_COLOR_VALUE ? null : swatchValue); + }; + + return ( +
+ {CELL_BG_SWATCHES.map((swatch, index) => { + const isSelected = + swatch.value === NO_COLOR_VALUE ? !value : swatch.value === value; + const isTopRow = index < CELL_BG_SWATCHES.length / 2; + + return ( + + + + ); + })} +
+ ); +}; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/colors.ts b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/colors.ts new file mode 100644 index 000000000..289631702 --- /dev/null +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/colors.ts @@ -0,0 +1,16 @@ +export const CELL_BG_SWATCHES = [ + {value: '', i18nKey: 'cells.bg.none'}, + {value: 'yellow-light', i18nKey: 'cells.bg.yellow-light'}, + {value: 'red-light', i18nKey: 'cells.bg.red-light'}, + {value: 'purple-light', i18nKey: 'cells.bg.purple-light'}, + {value: 'blue-light', i18nKey: 'cells.bg.blue-light'}, + {value: 'green-light', i18nKey: 'cells.bg.green-light'}, + {value: 'grey', i18nKey: 'cells.bg.grey'}, + {value: 'yellow', i18nKey: 'cells.bg.yellow'}, + {value: 'red', i18nKey: 'cells.bg.red'}, + {value: 'purple', i18nKey: 'cells.bg.purple'}, + {value: 'blue', i18nKey: 'cells.bg.blue'}, + {value: 'green', i18nKey: 'cells.bg.green'}, +] as const; + +export type CellBgValue = (typeof CELL_BG_SWATCHES)[number]['value']; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/index.ts b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/index.ts new file mode 100644 index 000000000..c6fb1f61a --- /dev/null +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/CellBgPalette/index.ts @@ -0,0 +1 @@ +export * from './CellBgPalette'; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenu/FloatingMenu.tsx b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenu/FloatingMenu.tsx index d9ef0017d..870f2818c 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenu/FloatingMenu.tsx +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenu/FloatingMenu.tsx @@ -22,7 +22,8 @@ export type FloatingMenuProps = { dirtype: 'row' | 'column'; canDrag: boolean; anchorElement: ReferenceType; - dropdownItems: DropdownMenuProps['items']; + dropdownItems?: DropdownMenuProps['items']; + children?: React.ReactNode; switcherMouseProps?: Pick< ButtonButtonProps, 'onMouseDown' | 'onMouseMove' | 'onMouseUp' | 'onMouseLeave' @@ -31,8 +32,15 @@ export type FloatingMenuProps = { }; export const FloatingMenu: React.FC = function YfmTableFloatingMenu(props) { - const {dirtype, canDrag, anchorElement, dropdownItems, switcherMouseProps, onOpenToggle} = - props; + const { + dirtype, + canDrag, + anchorElement, + dropdownItems, + children, + switcherMouseProps, + onOpenToggle, + } = props; const [isMenuOpened, setMenuOpened] = useState(false); const [isHovered, setHovered, unsetHovered] = useBooleanState(false); @@ -102,7 +110,9 @@ export const FloatingMenu: React.FC = function YfmTableFloati }} menuProps={{qa: `g-md-yfm-table-${dirtype}-menu`}} items={dropdownItems} - /> + > + {children} + ); }; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx index 983a65ae6..74a99d2f9 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx @@ -2,20 +2,21 @@ import {useMemo} from 'react'; import type {ClientRectObject, VirtualElement} from '@floating-ui/react'; import { - ArrowDown, - ArrowLeft, - ArrowRight, - ArrowUp, - BroomMotion as ClearCells, - LayoutHeader as HeaderRow, - TrashBin, - Xmark, + ArrowShapeRightFromLine as AddColumnAfterIcon, + ArrowShapeLeftFromLine as AddColumnBeforeIcon, + ArrowShapeDownFromLine as AddRowAfterIcon, + ArrowShapeUpFromLine as AddRowBeforeIcon, + BroomMotion as ClearCellsIcon, + LayoutHeader as HeaderRowIcon, + Xmark as RemoveRowOrColumnIcon, + TrashBin as RemoveTableIcon, } from '@gravity-ui/icons'; -import {type DropdownMenuItem, Flex, Icon, Switch} from '@gravity-ui/uikit'; +import {DropdownMenu, Flex, Icon, Menu, Switch} from '@gravity-ui/uikit'; import {i18n} from 'src/i18n/yfm-table'; import type {DnDControlHandler} from '../../dnd/dnd'; +import {CellBgMenuItem} from '../CellBgMenuItem'; import {FloatingMenu, type FloatingMenuProps} from '../FloatingMenu/FloatingMenu'; type ControlType = FloatingMenuProps['dirtype']; @@ -34,6 +35,8 @@ export type FloatingMenuControlProps = { onRemoveTableClick: () => void; isRowHeader?: boolean; onRowHeaderChange?: (value: boolean) => void; + currentCellBg?: string | null; + onCellBgChange?: (color: string | null) => void; }; export const FloatingMenuControl: React.FC = @@ -51,80 +54,88 @@ export const FloatingMenuControl: React.FC = onRemoveTableClick, isRowHeader = false, onRowHeaderChange, + currentCellBg, + onCellBgChange, }) { - const dropdownItems = useMemo(() => { - const headerItems: DropdownMenuItem[] = []; - if (onRowHeaderChange) { - headerItems.push({ - text: ( - - {i18n(`row.header${multiple ? '.multiple' : ''}`)} - , which would trigger action twice - style={{pointerEvents: 'none'}} + const dropdownContent = ( + + {(onRowHeaderChange || onCellBgChange) && ( + + {onRowHeaderChange && ( + } + text={ + + {i18n(`row.header${multiple ? '.multiple' : ''}`)} + , which would trigger action twice + style={{pointerEvents: 'none'}} + /> + + } + action={() => onRowHeaderChange(!isRowHeader)} /> - - ), - iconStart: , - qa: 'g-md-yfm-table-row-header-toggle', - action: () => onRowHeaderChange(!isRowHeader), - }); - } - - return [ - ...headerItems, - [ - { - text: i18n(`${type}.add.before`), - qa: `g-md-yfm-table-action-add-${type}-before`, - action: onInsertBeforeClick, - iconStart: , - }, - { - text: i18n(`${type}.add.after`), - qa: `g-md-yfm-table-action-add-${type}-after`, - action: onInsertAfterClick, - iconStart: , - }, - ], - [ - { - text: i18n('cells.clear'), - qa: `g-md-yfm-table-${type}-clear-cells`, - action: onClearCellsClick, - iconStart: , - }, - ], - [ - { - text: i18n(`${type}.remove${multiple ? '.multiple' : ''}`), - qa: `g-md-yfm-table-action-remove-${type}`, - action: onRemoveRangeClick, - iconStart: , - }, - { - theme: 'danger', - text: i18n('table.remove'), - qa: 'g-md-yfm-table-action-remove-table', - action: onRemoveTableClick, - iconStart: , - }, - ], - ] satisfies FloatingMenuProps['dropdownItems']; - }, [ - type, - multiple, - isRowHeader, - onRowHeaderChange, - onClearCellsClick, - onInsertAfterClick, - onInsertBeforeClick, - onRemoveRangeClick, - onRemoveTableClick, - ]); + )} + {onCellBgChange && ( + + )} + + )} + + + + } + text={i18n(`${type}.add.before`)} + action={onInsertBeforeClick} + /> + + } + text={i18n(`${type}.add.after`)} + action={onInsertAfterClick} + /> + } + text={i18n('cells.clear')} + action={onClearCellsClick} + /> + + + + } + text={i18n(`${type}.remove${multiple ? '.multiple' : ''}`)} + action={onRemoveRangeClick} + /> + } + text={i18n('table.remove')} + action={onRemoveTableClick} + /> + + + ); const anchor = useMemo( () => getVirtualAnchor(type, tableElement, cellElement), @@ -147,8 +158,9 @@ export const FloatingMenuControl: React.FC = } : undefined } - dropdownItems={dropdownItems} - /> + > + {dropdownContent} + ); }; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss index 3f90f56ef..fd7953aa5 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss @@ -55,7 +55,10 @@ overflow: unset; border-color: var(--g-color-line-brand); - background-color: var(--g-color-base-selection); + + &:not([class*='cell-bg-']) { + background-color: var(--g-color-base-selection); + } &::after { position: absolute; diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/index.ts b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/index.ts index e43a688b8..a0e6ce5e1 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/index.ts +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/index.ts @@ -7,6 +7,7 @@ import {yfmTableHeaderRowsPlugin} from './plugins/header-rows-plugin'; export type YfmTableControlsPluginsOpts = { dndEnabled: boolean; headerRowsEnabled: boolean; + cellBackgroundEnabled: boolean; }; export const yfmTableControlsPlugins = diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx index 229da4779..61a05e074 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx @@ -21,6 +21,7 @@ import {insertEmptyColumn} from '../commands/insert-empty-column'; import {insertEmptyRow} from '../commands/insert-empty-row'; import {removeColumnRange} from '../commands/remove-column-range'; import {removeRowRange} from '../commands/remove-row-range'; +import {setCellBg} from '../commands/set-cell-bg'; import {canMakeRowHeader, toggleHeaderRows} from '../commands/toggle-header-rows'; import {FloatingMenuControl} from '../components/FloatingMenuControl'; import { @@ -48,6 +49,7 @@ type GetPos = () => number | undefined; type YfmTableCellViewOptions = { dndEnabled: boolean; headerRowsEnabled: boolean; + cellBackgroundEnabled: boolean; }; export const yfmTableCellView = @@ -67,6 +69,7 @@ class YfmTableCellView implements NodeView { private readonly _logger: Logger2.ILogger; private readonly _dndEnabled: boolean; private readonly _headerRowsEnabled: boolean; + private readonly _cellBackgroundEnabled: boolean; private _isHeader: boolean; private _decoRowUniqKey: number | null = null; @@ -100,6 +103,7 @@ class YfmTableCellView implements NodeView { }); this._dndEnabled = opts.dndEnabled; this._headerRowsEnabled = opts.headerRowsEnabled; + this._cellBackgroundEnabled = opts.cellBackgroundEnabled; this._isHeader = this._computeIsHeader(undefined); this.dom = document.createElement('td'); @@ -119,6 +123,8 @@ class YfmTableCellView implements NodeView { const tableElem = this._view.domAtPos(tablePos + 1).node as Element; + const currentCellBg = this._node.attrs[YfmTableAttr.CellBg] ?? null; + return ( {showRowControl && ( @@ -140,6 +146,10 @@ class YfmTableCellView implements NodeView { ? this._onRowHeaderChange : undefined } + currentCellBg={currentCellBg} + onCellBgChange={ + this._cellBackgroundEnabled ? this._onRowSetCellBg : undefined + } /> )} {showColumnControl && ( @@ -155,6 +165,12 @@ class YfmTableCellView implements NodeView { onInsertAfterClick={this._onColumnInsertAfterClick} onRemoveRangeClick={this._onColumnRemoveRangeClick} onRemoveTableClick={this._onRemoveTableClick} + currentCellBg={currentCellBg} + onCellBgChange={ + this._cellBackgroundEnabled + ? this._onColumnSetCellBg + : undefined + } /> )} @@ -240,6 +256,10 @@ class YfmTableCellView implements NodeView { this.dom.classList.remove(prev.attrs[YfmTableAttr.CellAlign]); } + if (prev?.attrs[YfmTableAttr.CellBg]) { + this.dom.classList.remove(`cell-bg-${prev.attrs[YfmTableAttr.CellBg]}`); + } + if (this._node.attrs[YfmTableAttr.Colspan]) this.dom.setAttribute('colspan', this._node.attrs[YfmTableAttr.Colspan]); else this.dom.removeAttribute('colspan'); @@ -254,6 +274,13 @@ class YfmTableCellView implements NodeView { } else { this.dom.removeAttribute(YfmTableAttr.CellAlign); } + + if (this._node.attrs[YfmTableAttr.CellBg] && this._cellBackgroundEnabled) { + this.dom.classList.add(`cell-bg-${this._node.attrs[YfmTableAttr.CellBg]}`); + this.dom.setAttribute(YfmTableAttr.CellBg, this._node.attrs[YfmTableAttr.CellBg]); + } else { + this.dom.removeAttribute(YfmTableAttr.CellBg); + } } private _onRowControlOpenToggle = (open: boolean) => { @@ -334,6 +361,34 @@ class YfmTableCellView implements NodeView { this._view.focus(); }; + private _onRowSetCellBg = (bg: string | null) => { + this._logger.event({event: 'row-set-cell-bg', source: 'row-menu'}); + + const info = this._getCellInfo(); + if (info) { + const rowRange = info.tableDesc.base.getRowRangeByRowIdx(info.cell.row); + setCellBg({ + tablePos: info.table.pos, + rows: iterate(rowRange.startIdx, rowRange.endIdx + 1), + bg, + })(this._view.state, this._view.dispatch); + } + }; + + private _onColumnSetCellBg = (bg: string | null) => { + this._logger.event({event: 'column-set-cell-bg', source: 'column-menu'}); + + const info = this._getCellInfo(); + if (info) { + const colRange = info.tableDesc.base.getColumnRangeByColumnIdx(info.cell.column); + setCellBg({ + tablePos: info.table.pos, + cols: iterate(colRange.startIdx, colRange.endIdx + 1), + bg, + })(this._view.state, this._view.dispatch); + } + }; + private _onRowInsertBeforeClick = () => { this._logger.event({event: 'row-insert-before', source: 'row-menu'}); diff --git a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/plugins/focus-plugin.ts b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/plugins/focus-plugin.ts index faec0d5bb..9c6819569 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/plugins/focus-plugin.ts +++ b/packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/plugins/focus-plugin.ts @@ -31,7 +31,11 @@ function shouldUpdateState(prev: HoverState, curr: HoverState): boolean { return true; } -export const yfmTableFocusPlugin = (opts: {dndEnabled: boolean; headerRowsEnabled: boolean}) => { +export const yfmTableFocusPlugin = (opts: { + dndEnabled: boolean; + headerRowsEnabled: boolean; + cellBackgroundEnabled: boolean; +}) => { return new Plugin({ key: pluginKey, state: { diff --git a/packages/editor/src/i18n/yfm-table/en.json b/packages/editor/src/i18n/yfm-table/en.json index 1276cd2e5..1ec90b00c 100644 --- a/packages/editor/src/i18n/yfm-table/en.json +++ b/packages/editor/src/i18n/yfm-table/en.json @@ -10,6 +10,19 @@ "row.remove": "Remove row", "row.remove.multiple": "Remove rows", "cells.clear": "Clear cells", + "cells.bg": "Background color", + "cells.bg.none": "No color", + "cells.bg.grey": "Grey", + "cells.bg.yellow-light": "Light yellow", + "cells.bg.yellow": "Yellow", + "cells.bg.red-light": "Light red", + "cells.bg.red": "Red", + "cells.bg.purple-light": "Light purple", + "cells.bg.purple": "Purple", + "cells.bg.blue-light": "Light blue", + "cells.bg.blue": "Blue", + "cells.bg.green-light": "Light green", + "cells.bg.green": "Green", "table.remove": "Remove table", "table.menu.cell.align.left": "Align cell content to the left", "table.menu.cell.align.right": "Align cell content to the right", diff --git a/packages/editor/src/i18n/yfm-table/ru.json b/packages/editor/src/i18n/yfm-table/ru.json index 43e4542b1..2cbe0acbb 100644 --- a/packages/editor/src/i18n/yfm-table/ru.json +++ b/packages/editor/src/i18n/yfm-table/ru.json @@ -10,6 +10,19 @@ "row.remove": "Удалить строку", "row.remove.multiple": "Удалить строки", "cells.clear": "Очистить ячейки", + "cells.bg": "Цвет фона", + "cells.bg.none": "Без цвета", + "cells.bg.grey": "Серый", + "cells.bg.yellow-light": "Светло-жёлтый", + "cells.bg.yellow": "Жёлтый", + "cells.bg.red-light": "Светло-красный", + "cells.bg.red": "Красный", + "cells.bg.purple-light": "Светло-фиолетовый", + "cells.bg.purple": "Фиолетовый", + "cells.bg.blue-light": "Светло-синий", + "cells.bg.blue": "Синий", + "cells.bg.green-light": "Светло-зелёный", + "cells.bg.green": "Зелёный", "table.remove": "Удалить таблицу", "table.menu.cell.align.left": "Выровнять контент ячейки по левому краю", "table.menu.cell.align.right": "Выровнять контент ячейки по правому краю", diff --git a/packages/editor/src/styles/markdown.scss b/packages/editor/src/styles/markdown.scss index a8c5fbc95..5dc2e2e56 100644 --- a/packages/editor/src/styles/markdown.scss +++ b/packages/editor/src/styles/markdown.scss @@ -1,4 +1,5 @@ @use '../extensions/yfm/Color/colors'; @use './yc-file.scss'; +@use './yc-table.scss'; @use './yc-colors.scss'; @use './yfm-overrides.scss'; diff --git a/packages/editor/src/styles/yc-table.scss b/packages/editor/src/styles/yc-table.scss new file mode 100644 index 000000000..cf90495f1 --- /dev/null +++ b/packages/editor/src/styles/yc-table.scss @@ -0,0 +1,36 @@ +.yfm table th, +.yfm table td { + &.cell-bg-grey { + background-color: var(--g-color-base-generic); + } + &.cell-bg-yellow-light { + background-color: var(--g-color-base-warning-light); + } + &.cell-bg-yellow { + background-color: var(--g-color-base-warning-medium); + } + &.cell-bg-red-light { + background-color: var(--g-color-base-danger-light); + } + &.cell-bg-red { + background-color: var(--g-color-base-danger-medium); + } + &.cell-bg-purple-light { + background-color: var(--g-color-base-utility-light); + } + &.cell-bg-purple { + background-color: var(--g-color-base-utility-medium); + } + &.cell-bg-blue-light { + background-color: var(--g-color-base-info-light); + } + &.cell-bg-blue { + background-color: var(--g-color-base-info-medium); + } + &.cell-bg-green-light { + background-color: var(--g-color-base-positive-light); + } + &.cell-bg-green { + background-color: var(--g-color-base-positive-medium); + } +}