From 83bf8acd93df8308bdcbb6b805b61b13fe4dfdb3 Mon Sep 17 00:00:00 2001 From: devburak Date: Fri, 18 Sep 2026 13:06:00 +0300 Subject: [PATCH 1/3] fix(admin): preserve table formatting across editor modes --- .../src/pages/contents/ContentEditor.css | 81 ++++++++++++++ .../src/pages/contents/ContentEditor.jsx | 72 +++++++++--- .../src/pages/contents/nodes/ImageNode.jsx | 28 +++++ .../pages/contents/nodes/ImageNode.test.jsx | 30 +++++ .../src/pages/contents/nodes/TableNode.jsx | 105 +++++++++++++++--- .../plugins/TableActionMenuPlugin.jsx | 53 +++++++++ .../plugins/TableCellFocusPlugin.test.jsx | 57 +++++++++- 7 files changed, 393 insertions(+), 33 deletions(-) diff --git a/apps/admin/src/pages/contents/ContentEditor.css b/apps/admin/src/pages/contents/ContentEditor.css index d805df8..315de6d 100644 --- a/apps/admin/src/pages/contents/ContentEditor.css +++ b/apps/admin/src/pages/contents/ContentEditor.css @@ -1791,6 +1791,87 @@ body.editor-image-dialog-open .editor-image-context-toolbar { border: none; } +.table-text-alignment { + --table-align-surface: #ffffff; + --table-align-surface-active: #dbeafe; + --table-align-ink: #64748b; + --table-align-ink-active: #1d4ed8; + --table-align-border: #e2e8f0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; +} + +.table-text-alignment__label { + color: #374151; + font-size: 12px; + font-weight: 600; +} + +.table-text-alignment__controls { + display: inline-grid; + grid-template-columns: repeat(3, 32px); + gap: 4px; + padding: 4px; + border: 1px solid var(--table-align-border); + border-radius: 8px; + background: #f8fafc; +} + +.table-text-alignment__button { + display: inline-flex; + width: 32px; + height: 28px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 6px; + color: var(--table-align-ink); + background: transparent; +} + +.table-text-alignment__button:hover, +.table-text-alignment__button:focus-visible { + color: var(--table-align-ink-active); + background: var(--table-align-surface); + outline: none; + box-shadow: 0 0 0 2px #bfdbfe; +} + +.table-text-alignment__button.is-active { + color: var(--table-align-ink-active); + background: var(--table-align-surface-active); +} + +.table-text-alignment__icon { + display: flex; + width: 14px; + flex-direction: column; + gap: 2px; +} + +.table-text-alignment__icon i { + display: block; + width: 14px; + height: 1.5px; + border-radius: 999px; + background: currentColor; +} + +.table-text-alignment__icon i:nth-child(2) { + width: 9px; +} + +.table-text-alignment__icon--center i:nth-child(2) { + align-self: center; +} + +.table-text-alignment__icon--right i:nth-child(2) { + align-self: flex-end; +} + /* Color Picker Styles */ .table-action-item-with-submenu { position: relative; diff --git a/apps/admin/src/pages/contents/ContentEditor.jsx b/apps/admin/src/pages/contents/ContentEditor.jsx index de5dd85..68fdc46 100644 --- a/apps/admin/src/pages/contents/ContentEditor.jsx +++ b/apps/admin/src/pages/contents/ContentEditor.jsx @@ -28,6 +28,7 @@ import { $getRoot, $getSelection, $getNodeByKey, + $isElementNode, $isRangeSelection, $isNodeSelection, $isTextNode, @@ -36,7 +37,6 @@ import { CAN_REDO_COMMAND, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, - FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, REDO_COMMAND, SELECTION_CHANGE_COMMAND, @@ -90,7 +90,9 @@ import { $createTableWithDimensions, $createTableNode, $createTableRowNode, - $createTableCellNode + $createTableCellNode, + $isTableCellNode, + $setTableCellTextAlignment, } from './nodes/TableNode.jsx' import { PhotoIcon, TrashIcon } from '@heroicons/react/24/outline' import MediaPickerModal from './components/MediaPickerModal.jsx' @@ -3196,6 +3198,23 @@ const BLOCK_OPTIONS = [ { value: 'code', label: 'Kod Bloğu' }, ] +function getNearestEditableBlock(node) { + if (!node) return null + + let current = $isElementNode(node) ? node : node.getParent() + while (current) { + if ($isTableCellNode(current)) { + return current + } + if ($isElementNode(current) && !current.isInline()) { + return current + } + current = current.getParent() + } + + return null +} + function Toolbar({ openMediaPicker = null, isTableSelectorOpen = false, @@ -3428,6 +3447,35 @@ function Toolbar({ [editor, blockType] ) + const applyBlockAlignment = useCallback((alignment) => { + editor.update(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection)) return + + const targets = new Map() + selection.getNodes().forEach((node) => { + const target = getNearestEditableBlock(node) + if (target) { + targets.set(target.getKey(), target) + } + }) + + // A collapsed caret in an empty block may not yield a node list. + if (targets.size === 0) { + const target = getNearestEditableBlock(selection.anchor.getNode()) + if (target) targets.set(target.getKey(), target) + } + + targets.forEach((target) => { + if ($isTableCellNode(target)) { + $setTableCellTextAlignment(target, alignment) + } else { + target.setFormat(alignment) + } + }) + }) + }, [editor]) + const updateToolbar = useCallback(() => { editor.getEditorState().read(() => { const selection = $getSelection() @@ -3444,17 +3492,9 @@ function Toolbar({ }) const anchorNode = selection.anchor.getNode() - let element = null - - if (anchorNode.getType() === 'root') { - element = anchorNode.getLastChild() - if (!element) { - setBlockType('paragraph') - setBlockFormat('left') - } - } else { - element = anchorNode.getTopLevelElementOrThrow() - } + let element = anchorNode.getType() === 'root' + ? anchorNode.getLastChild() + : getNearestEditableBlock(anchorNode) if (!element || element.getType() === 'root') { element = element?.getLastChild() || null @@ -3813,19 +3853,19 @@ function Toolbar({ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left')} + onClick={() => applyBlockAlignment('left')} active={blockFormat === 'left'} > editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center')} + onClick={() => applyBlockAlignment('center')} active={blockFormat === 'center'} > editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')} + onClick={() => applyBlockAlignment('right')} active={blockFormat === 'right'} > diff --git a/apps/admin/src/pages/contents/nodes/ImageNode.jsx b/apps/admin/src/pages/contents/nodes/ImageNode.jsx index 9ec7d7c..0f2e38c 100644 --- a/apps/admin/src/pages/contents/nodes/ImageNode.jsx +++ b/apps/admin/src/pages/contents/nodes/ImageNode.jsx @@ -53,6 +53,15 @@ export class ImageNode extends DecoratorNode { static importDOM() { return { + figure: (domNode) => { + if (!(domNode instanceof HTMLElement) || !domNode.querySelector('img')) { + return null + } + return { + conversion: convertImageFigureElement, + priority: 3, + } + }, img: (domNode) => { if (domNode instanceof HTMLImageElement) { return { @@ -280,6 +289,25 @@ export function $isImageNode(node) { export { DEFAULT_IMAGE_DIMENSION } +function convertImageFigureElement(domNode) { + const image = domNode.querySelector('img') + if (!(image instanceof HTMLImageElement)) { + return null + } + + const conversion = convertImageElement(image) + if (!conversion) { + return null + } + + return { + ...conversion, + // The caption is already stored on ImageNode. Consuming the figure's + // descendants prevents figcaption from becoming a second text block. + forChild: () => null, + } +} + function convertImageElement(domNode) { if (!(domNode instanceof HTMLImageElement)) { return null diff --git a/apps/admin/src/pages/contents/nodes/ImageNode.test.jsx b/apps/admin/src/pages/contents/nodes/ImageNode.test.jsx index 77eabc3..9c3f6c9 100644 --- a/apps/admin/src/pages/contents/nodes/ImageNode.test.jsx +++ b/apps/admin/src/pages/contents/nodes/ImageNode.test.jsx @@ -1,4 +1,5 @@ import { $getRoot, createEditor } from 'lexical' +import { $generateNodesFromDOM } from '@lexical/html' import { describe, expect, it } from 'vitest' import { $createImageNode, ImageNode } from './ImageNode.jsx' import { @@ -119,6 +120,35 @@ describe('ImageNode editable attributes', () => { expect(attributes).toEqual(['center', '', '_blank']) }) + it('imports a figure caption only as image metadata', () => { + const editor = createEditor({ nodes: [ImageNode], onError: (error) => { throw error } }) + const document = new DOMParser().parseFromString(` +
+
Ekip
Yıllık ekip buluşması
+
+ `, 'text/html') + let result + + editor.update(() => { + const nodes = $generateNodesFromDOM(editor, document) + $getRoot().append(...nodes) + const children = $getRoot().getChildren() + result = { + nodeCount: children.length, + nodeType: children[0]?.getType(), + caption: children[0]?.getCaption(), + trailingText: children.slice(1).map((node) => node.getTextContent()).join(''), + } + }, { discrete: true }) + + expect(result).toEqual({ + nodeCount: 1, + nodeType: 'image', + caption: 'Yıllık ekip buluşması', + trailingText: '', + }) + }) + }) describe('image setting boundaries', () => { diff --git a/apps/admin/src/pages/contents/nodes/TableNode.jsx b/apps/admin/src/pages/contents/nodes/TableNode.jsx index 5273e04..c81bdba 100644 --- a/apps/admin/src/pages/contents/nodes/TableNode.jsx +++ b/apps/admin/src/pages/contents/nodes/TableNode.jsx @@ -1,4 +1,33 @@ -import { ElementNode } from 'lexical' +import { $isElementNode, ElementNode } from 'lexical' + +const TABLE_TEXT_ALIGNMENTS = new Set(['left', 'center', 'right', 'justify', 'start', 'end']) + +function normalizeTextAlignment(value) { + return TABLE_TEXT_ALIGNMENTS.has(value) ? value : '' +} + +function restoreElementMetadata(node, serializedNode) { + node.setFormat(normalizeTextAlignment(serializedNode.format)) + node.setIndent(Number.isFinite(serializedNode.indent) ? serializedNode.indent : 0) + node.setDirection( + serializedNode.direction === 'rtl' || serializedNode.direction === 'ltr' + ? serializedNode.direction + : null + ) + return node +} + +function applyElementMetadataToDOM(node, element) { + const alignment = normalizeTextAlignment(node.getFormatType()) + if (alignment) { + element.style.textAlign = alignment + } + const direction = node.getDirection() + if (direction) { + element.dir = direction + } + return element +} export class TableNode extends ElementNode { __columnWidths @@ -33,7 +62,10 @@ export class TableNode extends ElementNode { static importJSON(serializedNode) { const { columnWidths, borderWidth, borderColor, borderStyle } = serializedNode - return $createTableNode(columnWidths, borderWidth, borderColor, borderStyle) + return restoreElementMetadata( + $createTableNode(columnWidths, borderWidth, borderColor, borderStyle), + serializedNode + ) } exportJSON() { @@ -153,7 +185,7 @@ export class TableRowNode extends ElementNode { static importJSON(serializedNode) { const { height } = serializedNode - return $createTableRowNode(height) + return restoreElementMetadata($createTableRowNode(height), serializedNode) } exportJSON() { @@ -241,7 +273,10 @@ export class TableCellNode extends ElementNode { static importJSON(serializedNode) { const { headerState, width, backgroundColor, colSpan, rowSpan, borderWidth, borderColor, borderStyle } = serializedNode - return $createTableCellNode(headerState, width, backgroundColor, colSpan, rowSpan, borderWidth, borderColor, borderStyle) + return restoreElementMetadata( + $createTableCellNode(headerState, width, backgroundColor, colSpan, rowSpan, borderWidth, borderColor, borderStyle), + serializedNode + ) } exportJSON() { @@ -287,6 +322,14 @@ export class TableCellNode extends ElementNode { return cell } + exportDOM(editor) { + const { element } = super.exportDOM(editor) + if (element instanceof HTMLElement) { + applyElementMetadataToDOM(this, element) + } + return { element } + } + updateDOM(prevNode, dom) { let needsUpdate = false @@ -471,18 +514,24 @@ function convertTableCellElement(domNode) { const colSpan = Number.isNaN(colSpanAttr) ? 1 : colSpanAttr const rowSpan = Number.isNaN(rowSpanAttr) ? 1 : rowSpanAttr - return { - node: $createTableCellNode( - headerState, - width, - backgroundColor || null, - colSpan, - rowSpan, - parsedBorder.width, - parsedBorder.color, - parsedBorder.style - ), - } + const node = $createTableCellNode( + headerState, + width, + backgroundColor || null, + colSpan, + rowSpan, + parsedBorder.width, + parsedBorder.color, + parsedBorder.style + ) + const alignment = normalizeTextAlignment( + domNode.style.textAlign || domNode.getAttribute('align') || '' + ) + if (alignment) { + node.setFormat(alignment) + } + + return { node } } function parseCssBorder(borderValue) { @@ -538,6 +587,30 @@ export function $isTableCellNode(node) { return node instanceof TableCellNode } +export function $getTableCellTextAlignment(cell) { + if (!$isTableCellNode(cell)) return 'left' + const firstFormattedChild = cell.getChildren().find((child) => ( + $isElementNode(child) && !child.isInline() && normalizeTextAlignment(child.getFormatType()) + )) + return normalizeTextAlignment(firstFormattedChild?.getFormatType()) + || normalizeTextAlignment(cell.getFormatType()) + || 'left' +} + +export function $setTableCellTextAlignment(cell, alignment) { + if (!$isTableCellNode(cell)) return + const normalizedAlignment = normalizeTextAlignment(alignment) || 'left' + + // Keep the cell format as a durable fallback for empty cells and apply the + // same value to every editable block so mixed-content cells remain coherent. + cell.setFormat(normalizedAlignment) + cell.getChildren().forEach((child) => { + if ($isElementNode(child) && !child.isInline()) { + child.setFormat(normalizedAlignment) + } + }) +} + // Table utility functions export function $createTableWithDimensions(rows, columns, includeHeaders = false) { const table = $createTableNode() diff --git a/apps/admin/src/pages/contents/plugins/TableActionMenuPlugin.jsx b/apps/admin/src/pages/contents/plugins/TableActionMenuPlugin.jsx index 2e892e9..ba54ec5 100644 --- a/apps/admin/src/pages/contents/plugins/TableActionMenuPlugin.jsx +++ b/apps/admin/src/pages/contents/plugins/TableActionMenuPlugin.jsx @@ -18,7 +18,9 @@ import { $isTableNode, $createTableRowNode, $createTableCellNode, + $getTableCellTextAlignment, $mergeCells, + $setTableCellTextAlignment, $unmergeCells } from '../nodes/TableNode.jsx' import { EDITOR_INTERACTION_CHANGE_EVENT } from '../utils/editorInteractionEvents.js' @@ -48,6 +50,7 @@ function TableActionMenuPlugin({ anchorElem: _anchorElem = document.body }) { const [showColorPicker, setShowColorPicker] = useState(false) const [showRowHeightPicker, setShowRowHeightPicker] = useState(false) const [showBorderPicker, setShowBorderPicker] = useState(false) + const [textAlignment, setTextAlignment] = useState('left') // Predefined row heights const rowHeights = [ @@ -709,6 +712,27 @@ function TableActionMenuPlugin({ anchorElem: _anchorElem = document.body }) { setShowColorPicker(false) }, [editor, selectedCells, tableCellNode]) + const setCellTextAlignment = useCallback((alignment) => { + if (!tableCellNode) return + + editor.update(() => { + const cells = selectedCells.length > 0 ? selectedCells : [tableCellNode] + cells.forEach((cell) => { + if ($isTableCellNode(cell)) { + $setTableCellTextAlignment(cell, alignment) + } + }) + }) + setTextAlignment(alignment) + }, [editor, selectedCells, tableCellNode]) + + useEffect(() => { + if (!tableCellNode) return + editor.getEditorState().read(() => { + setTextAlignment($getTableCellTextAlignment(tableCellNode)) + }) + }, [editor, tableCellNode, showMenu]) + const setRowHeight = useCallback((height) => { if (!tableCellNode) return @@ -865,6 +889,35 @@ function TableActionMenuPlugin({ anchorElem: _anchorElem = document.body }) { )} +
+ Metin hizası +
+ {[ + { value: 'left', label: 'Sola hizala' }, + { value: 'center', label: 'Ortala' }, + { value: 'right', label: 'Sağa hizala' }, + ].map((option) => ( + + ))} +
+
+ +
+