diff --git a/package-lock.json b/package-lock.json index 8b245420..7ff77c84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@fileverse-dev/ddoc", - "version": "2.4.23", + "version": "2.4.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fileverse-dev/ddoc", - "version": "2.4.23", + "version": "2.4.24", "dependencies": { "@_ueberdosis/prosemirror-tables": "^1.1.3", "@aarkue/tiptap-math-extension": "^1.3.3", "@fileverse-dev/md2slides": "^0.0.8", "@fileverse/crypto": "^0.0.13", - "@fileverse/ui": "^4.1.7-patch-21", + "@fileverse/ui": "4.1.7-patch-43", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@radix-ui/react-focus-scope": "^1.1.0", @@ -1061,9 +1061,9 @@ } }, "node_modules/@fileverse/ui": { - "version": "4.1.7-patch-21", - "resolved": "https://registry.npmjs.org/@fileverse/ui/-/ui-4.1.7-patch-21.tgz", - "integrity": "sha512-hDkfNrXIFZMWJx5Vjou5b8ivYkrfAZxmSufr9DJZOcpAcXccla3B41Wdg01UIrWVU1H5wfqSKttHlPT4oVOGMg==", + "version": "4.1.7-patch-43", + "resolved": "https://registry.npmjs.org/@fileverse/ui/-/ui-4.1.7-patch-43.tgz", + "integrity": "sha512-UfRq8VS3h9+3XIHZQUq3YusFaUNiBPYNw6uzInwlwJMOvOg65ZUNPKeT+umDw0KBz+sY7bOKplwIOBRUchb8wA==", "dependencies": { "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-checkbox": "^1.0.4", diff --git a/package.json b/package.json index cadcb45c..b71a1d74 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@fileverse-dev/ddoc", "private": false, "description": "DDoc", - "version": "2.4.23", + "version": "2.4.24", "main": "dist/index.es.js", "module": "dist/index.es.js", "exports": { @@ -41,7 +41,7 @@ "@aarkue/tiptap-math-extension": "^1.3.3", "@fileverse-dev/md2slides": "^0.0.8", "@fileverse/crypto": "^0.0.13", - "@fileverse/ui": "^4.1.7-patch-21", + "@fileverse/ui": "4.1.7-patch-43", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@radix-ui/react-focus-scope": "^1.1.0", diff --git a/package/components/editor-bubble-menu/editor-bubble-menu.tsx b/package/components/editor-bubble-menu/editor-bubble-menu.tsx index 1fea8142..cb66d5dc 100644 --- a/package/components/editor-bubble-menu/editor-bubble-menu.tsx +++ b/package/components/editor-bubble-menu/editor-bubble-menu.tsx @@ -12,6 +12,7 @@ import { ScriptsPopup, getCurrentFontSize, FontSizePicker, + LineHeightPicker, } from '../editor-utils'; import { IEditorTool } from '../../hooks/use-visibility'; import ToolbarButton from '../../common/toolbar-button'; @@ -28,6 +29,7 @@ import { ReminderMenu } from '../../extensions/reminder-block/reminder-menu'; import { useReminder } from '../../hooks/use-reminder'; const MemoizedFontSizePicker = React.memo(FontSizePicker); +const MemoizedLineHeightPicker = React.memo(LineHeightPicker); export const EditorBubbleMenu = (props: EditorBubbleMenuProps) => { const { @@ -49,7 +51,9 @@ export const EditorBubbleMenu = (props: EditorBubbleMenuProps) => { } = props; const editorStates = useEditorStates(editor as Editor); const currentSize = editor ? editorStates.currentSize : undefined; + const currentLineHeight = editor ? editorStates.currentLineHeight : undefined; const onSetFontSize = editor ? editorStates.onSetFontSize : () => {}; + const onSetLineHeight = editor ? editorStates.onSetLineHeight : () => {}; const { isNativeMobile } = useResponsive(); const { toolRef, setToolVisibility, toolVisibility } = useEditorToolbar({ editor: editor, @@ -353,6 +357,27 @@ export const EditorBubbleMenu = (props: EditorBubbleMenuProps) => { } /> + + } + content={ + + } + /> +
{items.map((item, index) => { diff --git a/package/components/editor-toolbar.tsx b/package/components/editor-toolbar.tsx index c3e13703..b6d5de69 100644 --- a/package/components/editor-toolbar.tsx +++ b/package/components/editor-toolbar.tsx @@ -5,6 +5,7 @@ import { fonts, FontSizePicker, getCurrentFontSize, + LineHeightPicker, LinkPopup, TextColor, TextHeading, @@ -31,6 +32,7 @@ import { IpfsImageFetchPayload, IpfsImageUploadResponse } from '../types'; import { ImportExportButton } from './import-export-button'; import { getCurrentFontFamily } from '../utils/get-current-font-family'; const MemoizedFontSizePicker = React.memo(FontSizePicker); +const MemoizedLineHeightPicker = React.memo(LineHeightPicker); const TiptapToolBar = ({ editor, @@ -95,6 +97,8 @@ const TiptapToolBar = ({ const editorStates = useEditorStates(editor as Editor); const currentSize = editor ? editorStates.currentSize : undefined; const onSetFontSize = editor ? editorStates.onSetFontSize : () => {}; + const currentLineHeight = editor ? editorStates.currentLineHeight : undefined; + const onSetLineHeight = editor ? editorStates.onSetLineHeight : () => {}; const isBelow1480px = useMediaQuery('(max-width: 1480px)'); @@ -173,6 +177,16 @@ const TiptapToolBar = ({ onError={onError} /> ); + case 'Line Height': + return ( + + ); default: return null; } @@ -405,6 +419,39 @@ const TiptapToolBar = ({ 'font-size-dropdown', )}
+ {/* Line Height Dropdown */} + {isLoading + ? fadeInTransition( + , + 'line-height-skeleton', + ) + : slideUpTransition( + + setToolVisibility(IEditorTool.LINE_HEIGHT)} + /> + + } + content={ + + } + />, + 'line-height-dropdown', + )} +
{/* Toolbar Items */}
@@ -422,7 +469,8 @@ const TiptapToolBar = ({ tool.title === 'Highlight' || tool.title === 'Text Color' || tool.title === 'Alignment' || - tool.title === 'Link' + tool.title === 'Link' || + tool.title === 'Line Height' ) { return !isLoading ? slideUpTransition( diff --git a/package/components/editor-utils.tsx b/package/components/editor-utils.tsx index b52e22df..a8e0241c 100644 --- a/package/components/editor-utils.tsx +++ b/package/components/editor-utils.tsx @@ -276,6 +276,46 @@ export const getCurrentFontSize = ( return currentSize ? currentSize.replace('px', '') : ''; }; +// Line height conversion helpers: UI shows numbers (1, 1.15, 1.5, etc.) but stores as percentages (120%, 138%, 180%, etc.) +// Formula: percentage = uiValue * 120 +const LINE_HEIGHT_BASE = 120; // 1 in UI = 120% in storage + +export const uiValueToPercentage = (uiValue: string): string => { + const num = parseFloat(uiValue); + return `${Math.round(num * LINE_HEIGHT_BASE)}%`; +}; + +export const percentageToUiValue = (percentage: string): string => { + const num = parseFloat(percentage.replace('%', '')); + return (num / LINE_HEIGHT_BASE).toString(); +}; + +export const LINE_HEIGHT_OPTIONS = [ + { value: '120%', label: '1', uiValue: '1', description: '' }, + { value: '138%', label: '1.15', uiValue: '1.15', description: '(Default)' }, + { value: '180%', label: '1.5', uiValue: '1.5', description: '' }, + { value: '240%', label: '2', uiValue: '2', description: '' }, + { value: '300%', label: '2.5', uiValue: '2.5', description: '' }, + { value: '360%', label: '3', uiValue: '3', description: '' }, +]; + +export const getLineHeightOptions = () => LINE_HEIGHT_OPTIONS; + +export const getCurrentLineHeight = ( + editor: Editor | null, + currentLineHeight?: string, +) => { + if (!editor) return '1.15'; + // currentLineHeight is stored as percentage, find matching label + if (currentLineHeight && currentLineHeight.includes('%')) { + const option = LINE_HEIGHT_OPTIONS.find( + (opt) => opt.value === currentLineHeight, + ); + return option ? option.label : percentageToUiValue(currentLineHeight); + } + return currentLineHeight || '1.15'; +}; + export const ERR_MSG_MAP = { IMAGE_SIZE: 'Image size should be less than 10MB', }; @@ -385,6 +425,67 @@ export const useEditorToolbar = ({ } return true; } + + // Line height increase shortcut (Alt + Shift + ↑) + if (event.altKey && event.shiftKey && event.key === 'ArrowUp') { + event.preventDefault(); + const lineHeights = [ + '120%', + '138%', + '180%', + '240%', + '300%', + '360%', + ]; + + // Get line height from current block node + let currentLineHeight = + editor.getAttributes('paragraph')?.lineHeight; + if (!currentLineHeight && editor.isActive('heading')) { + currentLineHeight = editor.getAttributes('heading')?.lineHeight; + } + if (!currentLineHeight && editor.isActive('listItem')) { + currentLineHeight = editor.getAttributes('listItem')?.lineHeight; + } + currentLineHeight = currentLineHeight || '138%'; + + const currentIndex = lineHeights.indexOf(currentLineHeight); + const nextIndex = Math.min( + currentIndex + 1, + lineHeights.length - 1, + ); + editor.chain().setLineHeight(lineHeights[nextIndex]).run(); + return true; + } + + // Line height decrease shortcut (Alt + Shift + ↓) + if (event.altKey && event.shiftKey && event.key === 'ArrowDown') { + event.preventDefault(); + const lineHeights = [ + '120%', + '138%', + '180%', + '240%', + '300%', + '360%', + ]; + + // Get line height from current block node + let currentLineHeight = + editor.getAttributes('paragraph')?.lineHeight; + if (!currentLineHeight && editor.isActive('heading')) { + currentLineHeight = editor.getAttributes('heading')?.lineHeight; + } + if (!currentLineHeight && editor.isActive('listItem')) { + currentLineHeight = editor.getAttributes('listItem')?.lineHeight; + } + currentLineHeight = currentLineHeight || '138%'; + + const currentIndex = lineHeights.indexOf(currentLineHeight); + const prevIndex = Math.max(currentIndex - 1, 0); + editor.chain().setLineHeight(lineHeights[prevIndex]).run(); + return true; + } return false; }, }, @@ -618,6 +719,12 @@ export const useEditorToolbar = ({ onClick: () => setToolVisibility(IEditorTool.ALIGNMENT), isActive: toolVisibility === IEditorTool.ALIGNMENT, }, + { + icon: 'LineHeight', + title: 'Line Height', + onClick: () => setToolVisibility(IEditorTool.LINE_HEIGHT), + isActive: toolVisibility === IEditorTool.LINE_HEIGHT, + }, { icon: 'TextQuote', title: 'Quote', @@ -1727,6 +1834,60 @@ export const FontSizePicker = ({ ); }; +export const LineHeightPicker = ({ + setVisibility, + elementRef, + currentLineHeight, + onSetLineHeight, +}: { + editor: Editor; + elementRef: React.RefObject; + setVisibility: Dispatch>; + currentLineHeight?: string; + onSetLineHeight: (lineHeight: string) => void; +}) => { + const lineHeightOptions = getLineHeightOptions(); + + return ( +
+ {lineHeightOptions.map((lineHeight) => ( + + ))} +
+ ); +}; + export const TextFormatingPopup = ({ editor, isOpen, diff --git a/package/extensions/d-block/dblock-node-view.tsx b/package/extensions/d-block/dblock-node-view.tsx index cdfa00a4..0eb4d430 100644 --- a/package/extensions/d-block/dblock-node-view.tsx +++ b/package/extensions/d-block/dblock-node-view.tsx @@ -379,7 +379,7 @@ export const DBlockNodeView: React.FC = React.memo( return ( { + lineHeight: { + /** + * Set the line height + */ + setLineHeight: (lineHeight: string) => ReturnType; + /** + * Unset the line height + */ + unsetLineHeight: () => ReturnType; + }; + } +} + +export const LineHeight = Extension.create({ + name: 'lineHeight', + + addOptions() { + return { + types: ['paragraph', 'heading', 'listItem'], + defaultLineHeight: '138%', // 1.15 in UI = 138% + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + lineHeight: { + default: this.options.defaultLineHeight, + parseHTML: (element) => + element.style.lineHeight?.replace(/['"]+/g, '') || + this.options.defaultLineHeight, + renderHTML: (attributes) => { + const lineHeight = + attributes.lineHeight || this.options.defaultLineHeight; + return { + style: `line-height: ${lineHeight}`, + }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setLineHeight: + (lineHeight: string) => + ({ tr, state, dispatch }) => { + const { selection } = state; + const { from, to } = selection; + + // Check if there's a selection + const hasSelection = from !== to; + + if (hasSelection) { + // Apply to selected nodes only + state.doc.nodesBetween(from, to, (node, pos) => { + if (this.options.types.includes(node.type.name)) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + lineHeight, + }); + } + }); + } else { + // No selection - apply to all nodes in the document + state.doc.descendants((node, pos) => { + if (this.options.types.includes(node.type.name)) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + lineHeight, + }); + } + }); + } + + if (dispatch) dispatch(tr); + return true; + }, + unsetLineHeight: + () => + ({ tr, state, dispatch }) => { + const { selection } = state; + const { from, to } = selection; + + // Check if there's a selection + const hasSelection = from !== to; + + if (hasSelection) { + // Remove from selected nodes only + state.doc.nodesBetween(from, to, (node, pos) => { + if (this.options.types.includes(node.type.name)) { + const newAttrs = { ...node.attrs }; + delete newAttrs.lineHeight; + tr.setNodeMarkup(pos, undefined, newAttrs); + } + }); + } else { + // No selection - remove from all nodes in the document + state.doc.descendants((node, pos) => { + if (this.options.types.includes(node.type.name)) { + const newAttrs = { ...node.attrs }; + delete newAttrs.lineHeight; + tr.setNodeMarkup(pos, undefined, newAttrs); + } + }); + } + + if (dispatch) dispatch(tr); + return true; + }, + }; + }, + + addProseMirrorPlugins() { + const pluginKey = new PluginKey('lineHeightInheritance'); + + return [ + new Plugin({ + key: pluginKey, + appendTransaction: (transactions, _oldState, newState) => { + const tr = newState.tr; + let modified = false; + + transactions.forEach((transaction) => { + if (!transaction.docChanged) return; + + transaction.steps.forEach((step) => { + const stepMap = step.getMap(); + stepMap.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + // Check if new content was inserted + if (newEnd > newStart) { + newState.doc.nodesBetween(newStart, newEnd, (node, pos) => { + // Only process our target node types + if (!this.options.types.includes(node.type.name)) return; + + // If node already has lineHeight, skip + if (node.attrs.lineHeight) return; + + // Try to find previous sibling with lineHeight + const $pos = newState.doc.resolve(pos); + const indexBefore = $pos.index($pos.depth); + + if (indexBefore > 0) { + const prevNode = $pos + .node($pos.depth) + .child(indexBefore - 1); + if ( + prevNode && + this.options.types.includes(prevNode.type.name) && + prevNode.attrs.lineHeight + ) { + // Copy lineHeight from previous node + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + lineHeight: prevNode.attrs.lineHeight, + }); + modified = true; + } + } + }); + } + }); + }); + }); + + return modified ? tr : null; + }, + }), + ]; + }, +}); diff --git a/package/hooks/use-editor-states.tsx b/package/hooks/use-editor-states.tsx index ff519428..6312258b 100644 --- a/package/hooks/use-editor-states.tsx +++ b/package/hooks/use-editor-states.tsx @@ -3,6 +3,7 @@ import { useCallback } from 'react'; type EditorStateResult = { currentSize: string | undefined; + currentLineHeight: string | undefined; }; export const useEditorStates = (editor: Editor | null) => { @@ -14,15 +15,47 @@ export const useEditorStates = (editor: Editor | null) => { [editor], ); + const onSetLineHeight = useCallback( + (lineHeight: string) => { + if (!editor) return; + const { from, to } = editor.state.selection; + const hasSelection = from !== to; + + if (hasSelection) { + // If there's a selection, keep focus to maintain the selection highlight + editor.chain().focus().setLineHeight(lineHeight).run(); + } else { + // If no selection (global change), don't focus to prevent canvas jump + editor.chain().setLineHeight(lineHeight).run(); + } + }, + [editor], + ); + const states = useEditorState({ editor, selector: (state: { editor: Editor | null }) => { - if (!state.editor) return { currentSize: undefined }; + if (!state.editor) + return { currentSize: undefined, currentLineHeight: undefined }; // First check if there's a custom font size set const customFontSize = state.editor.getAttributes('textStyle')?.fontSize; + + // Get line height from paragraph, heading, or listItem + let customLineHeight = + state.editor.getAttributes('paragraph')?.lineHeight; + if (!customLineHeight && state.editor.isActive('heading')) { + customLineHeight = state.editor.getAttributes('heading')?.lineHeight; + } + if (!customLineHeight && state.editor.isActive('listItem')) { + customLineHeight = state.editor.getAttributes('listItem')?.lineHeight; + } + if (customFontSize) { - return { currentSize: customFontSize }; + return { + currentSize: customFontSize, + currentLineHeight: customLineHeight || '138%', + }; } // If no custom size, check if it's a heading and use default sizes @@ -30,21 +63,34 @@ export const useEditorStates = (editor: Editor | null) => { const level = state.editor.getAttributes('heading').level; switch (level) { case 1: - return { currentSize: '32px' }; + return { + currentSize: '32px', + currentLineHeight: customLineHeight || '138%', + }; case 2: - return { currentSize: '24px' }; + return { + currentSize: '24px', + currentLineHeight: customLineHeight || '138%', + }; case 3: - return { currentSize: '18px' }; + return { + currentSize: '18px', + currentLineHeight: customLineHeight || '138%', + }; } } // Default size for regular text - return { currentSize: '16px' }; + return { + currentSize: '16px', + currentLineHeight: customLineHeight || '138%', + }; }, }) as EditorStateResult; return { ...states, onSetFontSize, + onSetLineHeight, }; }; diff --git a/package/hooks/use-visibility.tsx b/package/hooks/use-visibility.tsx index 462bf975..718580aa 100644 --- a/package/hooks/use-visibility.tsx +++ b/package/hooks/use-visibility.tsx @@ -14,6 +14,7 @@ export enum IEditorTool { LINK_POPUP, SCRIPTS, FONT_SIZE, + LINE_HEIGHT, } export default function useComponentVisibility(initialIsVisible: boolean) { const [isComponentVisible, setIsComponentVisible] = diff --git a/package/styles/editor.scss b/package/styles/editor.scss index 60842d4d..35f8acc9 100644 --- a/package/styles/editor.scss +++ b/package/styles/editor.scss @@ -484,13 +484,15 @@ ul[data-type='taskList'] li > label input[type='checkbox'] { width: 20px; height: 20px; position: relative; - top: 3px; + top: 0; border: 2px solid #363b3f; border-radius: 4px; margin-right: 0.3rem; - display: grid; + display: inline-grid; place-content: center; transition: all 0.2s ease; + flex-shrink: 0; + vertical-align: middle; &:hover { background-color: hsla(var(--color-bg-default-hover));