@@ -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));