Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4aebc0e
build: add tiptap text align dependency
habibayman Jan 30, 2026
e54e72d
feat(texteditor): add basic toggle alignmen functionality
habibayman Jan 30, 2026
baac181
fix(texteditor): toolbar overflow logic after adding alignment button
habibayman Jan 30, 2026
d6b9621
fix(texteditor): disable toggling alignment for code blocks
habibayman Jan 30, 2026
c270ce4
feat(texteditor): add toggling text alignment button to touchscreens …
habibayman Jan 30, 2026
f2aa6e6
fix(texteditor): adjust image extension to account for node alignment
habibayman Jan 30, 2026
74341c4
feat(texteditor): adjust markdown serializer to wrap nodes in html ta…
habibayman Mar 5, 2026
22a5c2d
test(texteditor): cover markdown serializer text alignemnt serializin…
habibayman Mar 5, 2026
38a9805
fix(texteditor): listItem serializer short-circuiting its paragraph c…
habibayman Mar 5, 2026
c3d1cbe
fix(texteditor): include align in images dual saving syntax
habibayman Mar 5, 2026
2dd5003
feat(texteditor): adjust small text displaying to account for textAli…
habibayman Mar 5, 2026
f17bbf6
fix(texteditor): inverted disabling condition for overflowed align bu…
habibayman Mar 7, 2026
f3bc749
fix(texteditor): match dimensions for right align SVG with left align
habibayman Mar 7, 2026
dbc30f2
feat(texteditor): detect alignemnt of every node on cursor position
habibayman Mar 7, 2026
c7cf5ee
perf(texteditor): add a safeguard return string for the recursive mar…
habibayman Mar 8, 2026
42d8179
pef(texteditor): add a null guard return value for getEffectiveAlignment
habibayman Mar 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@
}

.editor-container small {
display: block;
margin: 4px 0;
font-size: 12px;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ const MESSAGES = {
context: 'Option to set text format to header 3',
},

// Text alignments
alignLeft: {
message: 'Align left',
context: 'Button to align text to the left',
},
alignRight: {
message: 'Align right',
context: 'Button to align text to the right',
},

// Accessibility labels
textFormattingToolbar: {
message: 'Text formatting toolbar',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@

<ToolbarDivider v-if="visibleCategories.includes('clipboard')" />

<!-- Text Alignment -->
<ToolbarButton
v-if="visibleCategories.includes('align')"
:title="alignAction.title"
:icon="alignAction.icon"
:is-active="alignAction.isActive"
:is-available="alignAction.isAvailable"
@click="alignAction.handler"
/>

<ToolbarDivider v-if="visibleCategories.includes('align')" />

<!-- Clear Formatting -->

<ToolbarButton
v-if="visibleCategories.includes('clearFormat')"
:title="clearFormatting$()"
Expand Down Expand Up @@ -221,6 +235,25 @@
</button>
</template>

<!-- Overflow Text Alignment -->
<template v-if="overflowCategories.includes('align')">
<button
class="dropdown-item"
:class="{ active: alignAction.isActive }"
role="menuitem"
:disabled="!alignAction.isAvailable"
@click="alignAction.handler"
>
<img
:src="alignAction.icon"
class="dropdown-item-icon"
alt=""
aria-hidden="true"
>
<span class="dropdown-item-text">{{ alignAction.title }}</span>
</button>
</template>

<!-- Overflow Clear Format -->
<template v-if="overflowCategories.includes('clearFormat')">
<button
Expand Down Expand Up @@ -345,6 +378,7 @@
script: 710,
lists: 650,
clearFormat: 560,
align: 530,
clipboard: 500,
textFormat: 400,
};
Expand All @@ -355,6 +389,7 @@
'script',
'lists',
'clearFormat',
'align',
'clipboard',
'textFormat',
];
Expand All @@ -365,6 +400,7 @@
canClearFormat,
historyActions,
textActions,
alignAction,
listActions,
scriptActions,
insertTools,
Expand Down Expand Up @@ -523,6 +559,7 @@
canClearFormat,
historyActions,
textActions,
alignAction,
listActions,
scriptActions,
insertTools,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>

<NodeViewWrapper class="image-node-wrapper">
<NodeViewWrapper :style="wrapperStyle">
<div
ref="containerRef"
class="image-node-view"
Expand Down Expand Up @@ -88,6 +88,21 @@
const compactThreshold = 200;
let resizeListeners = null;

// Compute wrapper style based on textAlign attribute
const wrapperStyle = computed(() => {
const align = props.node.attrs.textAlign || 'left';
const alignmentMap = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
justify: 'flex-start',
};
return {
display: 'flex',
justifyContent: alignmentMap[align] || 'flex-start',
};
});

// Create debounced version of saveSize function
const debouncedSaveSize = debounce(() => {
props.updateAttributes({
Expand Down Expand Up @@ -296,6 +311,7 @@
containerRef,
resizeHandleRef,
styleWidth,
wrapperStyle,
onResizeStart,
removeImage,
editImage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@
@click="action.handler"
/>
<ToolbarDivider />
<ToolbarButton
:title="alignAction.title"
:icon="alignAction.icon"
:is-active="alignAction.isActive"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The align button here doesn't bind :is-available="alignAction.isAvailable", unlike the desktop EditorToolbar.vue (line 77). This means the align button won't be disabled inside code blocks on mobile. Consider adding :is-available="alignAction.isAvailable" for parity.

@click="alignAction.handler"
/>
<ToolbarDivider />
<ToolbarButton
v-for="action in scriptActions"
:key="action.name"
Expand Down Expand Up @@ -131,7 +138,8 @@
textFormattingToolbar$,
} = getTipTapEditorStrings();

const { textActions, listActions, scriptActions, insertTools } = useToolbarActions(emit);
const { textActions, listActions, scriptActions, insertTools, alignAction } =
useToolbarActions(emit);

const { canIncreaseFormat, canDecreaseFormat, increaseFormat, decreaseFormat } =
useFormatControls();
Expand Down Expand Up @@ -200,6 +208,7 @@
listActions,
scriptActions,
insertTools,
alignAction,
toggleToolbar,
canIncreaseFormat,
canDecreaseFormat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Editor } from '@tiptap/vue-2';
import StarterKitExtension from '@tiptap/starter-kit';
import { Superscript } from '@tiptap/extension-superscript';
import { Subscript } from '@tiptap/extension-subscript';
import { TextAlign } from '@tiptap/extension-text-align';
import { Small } from '../extensions/SmallTextExtension';
import { Image } from '../extensions/Image';
import { CodeBlockSyntaxHighlight } from '../extensions/CodeBlockSyntaxHighlight';
Expand Down Expand Up @@ -30,6 +31,9 @@ export function useEditor() {
Image,
CustomLink, // Use our custom Link extension
Math,
TextAlign.configure({
types: ['heading', 'paragraph', 'image', 'small'],
}),
],
content: content || '<p></p>',
editorProps: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ import { sanitizePastedHTML } from '../utils/markdown';
export function useToolbarActions(emit) {
const editor = inject('editor', null);

// helper
const getEffectiveAlignment = editorInstance => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: getEffectiveAlignment is a clean solution to the bidi/RTL concern — checking isActive first for explicit alignment, then falling back to getComputedStyle for browser-resolved direction. The null guard at line 10 keeps it consistent with the rest of the composable's defensive patterns.

if (!editorInstance) return 'left';

const isLeftAligned = editorInstance.isActive({ textAlign: 'left' });
const isRightAligned = editorInstance.isActive({ textAlign: 'right' });

if (isLeftAligned) return 'left';
if (isRightAligned) return 'right';

const { from } = editorInstance.state.selection;
const dom = editorInstance.view.domAtPos(from).node;
const el = dom.nodeType === 1 ? dom : dom.parentElement;

return el ? window.getComputedStyle(el).textAlign : 'left';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Smart fallback to getComputedStyle for detecting browser-resolved bidi alignment — this handles the RTL auto-alignment case that pure attribute checking would miss.

};

const {
undo$,
redo$,
Expand All @@ -21,6 +38,8 @@ export function useToolbarActions(emit) {
mathFormula$,
codeBlock$,
clipboardAccessFailed$,
alignLeft$,
alignRight$,
} = getTipTapEditorStrings();

// Action handlers
Expand Down Expand Up @@ -181,6 +200,18 @@ export function useToolbarActions(emit) {
}
};

const handleToggleAlign = () => {
if (!editor?.value) return;

const align = getEffectiveAlignment(editor.value);

editor.value
.chain()
.focus()
.setTextAlign(align === 'right' ? 'left' : 'right')
.run();
};

const handleBulletList = () => {
if (editor?.value) {
editor.value.chain().focus().toggleBulletList().run();
Expand Down Expand Up @@ -418,6 +449,23 @@ export function useToolbarActions(emit) {
handler: handleMinimize,
};

const alignAction = computed(() => {
const editorInstance = editor?.value;
const effectiveAlign = getEffectiveAlignment(editorInstance);
Comment thread
habibayman marked this conversation as resolved.
const effectiveRight = effectiveAlign === 'right';

return {
name: 'toggleAlign',
title: effectiveRight ? alignLeft$() : alignRight$(),
icon: effectiveRight
? require('../../assets/icon-alignLeft.svg')
: require('../../assets/icon-alignRight.svg'),
handler: handleToggleAlign,
isActive: false,
isAvailable: !isMarkActive('codeBlock'),
};
});

return {
// Individual handlers
handleUndo,
Expand All @@ -429,6 +477,7 @@ export function useToolbarActions(emit) {
handleCopy,
handlePaste,
handlePasteNoFormat,
handleToggleAlign,
handleBulletList,
handleNumberList,
handleSubscript,
Expand All @@ -444,6 +493,7 @@ export function useToolbarActions(emit) {
// Action arrays
historyActions,
textActions,
alignAction,
listActions,
scriptActions,
insertTools,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ export const Image = Node.create({
alt: { default: null },
width: { default: null },
height: { default: null },
textAlign: {
default: 'left',
parseHTML: element => {
const align = element.style.textAlign || element.getAttribute('data-text-align');
return align || 'left';
},
renderHTML: attributes => {
if (!attributes.textAlign || attributes.textAlign === 'left') {
return {};
}
return { 'data-text-align': attributes.textAlign };
},
},
};
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export const Small = Node.create({
class: {
default: 'small-text',
},
textAlign: {
default: 'left',
Comment thread
habibayman marked this conversation as resolved.
parseHTML: element => element.style.textAlign || 'left',
renderHTML: attributes => {
if (!attributes.textAlign || attributes.textAlign === 'left') return {};
return { style: `text-align: ${attributes.textAlign}` };
},
},
};
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { storageUrl } from '../../../../vuex/file/utils';

// --- Image Translation ---
export const IMAGE_PLACEHOLDER = '${☣ CONTENTSTORAGE}';
export const IMAGE_REGEX = /!\[([^\]]*)\]\(([^/]+\/[^\s=)]+)(?:\s*=\s*([0-9.]+)x([0-9.]+))?\)/g;
export const IMAGE_REGEX =
/!\[([^\]]*)\]\(([^/]+\/[^\s=)]+)(?:\s*=\s*([0-9.]+)x([0-9.]+))?(?:\s+align=(\w+))?\)/g;

export const imageMdToParams = markdown => {
// Reset regex state before executing to ensure it works on all matches
IMAGE_REGEX.lastIndex = 0;
const match = IMAGE_REGEX.exec(markdown);
if (!match) return null;

const [, alt, fullPath, width, height] = match;
const [, alt, fullPath, width, height, align] = match;

// Extract just the filename from the full path
const checksumWithExt = fullPath.split('/').pop();
Expand All @@ -24,17 +25,18 @@ export const imageMdToParams = markdown => {
const checksum = parts.join('.');

// Return the data with the correct property names that the rest of the system expects.
return { checksum, extension, alt: alt || '', width, height };
return { checksum, extension, alt: alt || '', width, height, align };
};

export const paramsToImageMd = ({ src, alt, width, height, permanentSrc }) => {
export const paramsToImageMd = ({ src, alt, width, height, permanentSrc, textAlign }) => {
const sourceToSave = permanentSrc || src;

const fileName = sourceToSave.split('/').pop();
const alignSuffix = textAlign && textAlign !== 'left' ? ` align=${textAlign.trim()}` : '';
if (Number.isFinite(+width) && Number.isFinite(+height)) {
return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName} =${width}x${height})`;
return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName} =${width}x${height}${alignSuffix})`;
}
return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName})`;
return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName}${alignSuffix})`;
};

// --- Math/Formula Translation ---
Expand Down Expand Up @@ -134,12 +136,13 @@ export function preprocessMarkdown(markdown) {
// 2. The permanentSrc is just the checksum + extension.
const permanentSrc = `${params.checksum}.${params.extension}`;

// 3. Create attributes string for width and height only if they exist
// 3. Create attributes string for width, height, and alignment only if they exist
const widthAttr = params.width ? ` width="${params.width}"` : '';
const heightAttr = params.height ? ` height="${params.height}"` : '';
const alignAttr = params.align ? ` style="text-align: ${params.align}"` : '';

// 4. Create an <img> tag with the REAL display URL in `src`.
return `<img src="${displayUrl}" permanentSrc="${permanentSrc}" alt="${params.alt}"${widthAttr}${heightAttr} />`;
return `<img src="${displayUrl}" permanentSrc="${permanentSrc}" alt="${params.alt}"${widthAttr}${heightAttr}${alignAttr} />`;
});

processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => {
Expand Down
Loading
Loading