Skip to content

Commit 6344d05

Browse files
Merge pull request #5677 from habibayman/feat/RTE-text-align
feat(texteditor): add text alignment toggling buttons
2 parents 6f4929c + 42d8179 commit 6344d05

16 files changed

Lines changed: 394 additions & 121 deletions

File tree

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@
410410
}
411411
412412
.editor-container small {
413+
display: block;
413414
margin: 4px 0;
414415
font-size: 12px;
415416
}

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditorStrings.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ const MESSAGES = {
103103
context: 'Option to set text format to header 3',
104104
},
105105

106+
// Text alignments
107+
alignLeft: {
108+
message: 'Align left',
109+
context: 'Button to align text to the left',
110+
},
111+
alignRight: {
112+
message: 'Align right',
113+
context: 'Button to align text to the right',
114+
},
115+
106116
// Accessibility labels
107117
textFormattingToolbar: {
108118
message: 'Text formatting toolbar',

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@
6868

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

71+
<!-- Text Alignment -->
72+
<ToolbarButton
73+
v-if="visibleCategories.includes('align')"
74+
:title="alignAction.title"
75+
:icon="alignAction.icon"
76+
:is-active="alignAction.isActive"
77+
:is-available="alignAction.isAvailable"
78+
@click="alignAction.handler"
79+
/>
80+
81+
<ToolbarDivider v-if="visibleCategories.includes('align')" />
82+
83+
<!-- Clear Formatting -->
84+
7185
<ToolbarButton
7286
v-if="visibleCategories.includes('clearFormat')"
7387
:title="clearFormatting$()"
@@ -221,6 +235,25 @@
221235
</button>
222236
</template>
223237

238+
<!-- Overflow Text Alignment -->
239+
<template v-if="overflowCategories.includes('align')">
240+
<button
241+
class="dropdown-item"
242+
:class="{ active: alignAction.isActive }"
243+
role="menuitem"
244+
:disabled="!alignAction.isAvailable"
245+
@click="alignAction.handler"
246+
>
247+
<img
248+
:src="alignAction.icon"
249+
class="dropdown-item-icon"
250+
alt=""
251+
aria-hidden="true"
252+
>
253+
<span class="dropdown-item-text">{{ alignAction.title }}</span>
254+
</button>
255+
</template>
256+
224257
<!-- Overflow Clear Format -->
225258
<template v-if="overflowCategories.includes('clearFormat')">
226259
<button
@@ -345,6 +378,7 @@
345378
script: 710,
346379
lists: 650,
347380
clearFormat: 560,
381+
align: 530,
348382
clipboard: 500,
349383
textFormat: 400,
350384
};
@@ -355,6 +389,7 @@
355389
'script',
356390
'lists',
357391
'clearFormat',
392+
'align',
358393
'clipboard',
359394
'textFormat',
360395
];
@@ -365,6 +400,7 @@
365400
canClearFormat,
366401
historyActions,
367402
textActions,
403+
alignAction,
368404
listActions,
369405
scriptActions,
370406
insertTools,
@@ -523,6 +559,7 @@
523559
canClearFormat,
524560
historyActions,
525561
textActions,
562+
alignAction,
526563
listActions,
527564
scriptActions,
528565
insertTools,

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22

3-
<NodeViewWrapper class="image-node-wrapper">
3+
<NodeViewWrapper :style="wrapperStyle">
44
<div
55
ref="containerRef"
66
class="image-node-view"
@@ -88,6 +88,21 @@
8888
const compactThreshold = 200;
8989
let resizeListeners = null;
9090
91+
// Compute wrapper style based on textAlign attribute
92+
const wrapperStyle = computed(() => {
93+
const align = props.node.attrs.textAlign || 'left';
94+
const alignmentMap = {
95+
left: 'flex-start',
96+
center: 'center',
97+
right: 'flex-end',
98+
justify: 'flex-start',
99+
};
100+
return {
101+
display: 'flex',
102+
justifyContent: alignmentMap[align] || 'flex-start',
103+
};
104+
});
105+
91106
// Create debounced version of saveSize function
92107
const debouncedSaveSize = debounce(() => {
93108
props.updateAttributes({
@@ -296,6 +311,7 @@
296311
containerRef,
297312
resizeHandleRef,
298313
styleWidth,
314+
wrapperStyle,
299315
onResizeStart,
300316
removeImage,
301317
editImage,

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@
8282
@click="action.handler"
8383
/>
8484
<ToolbarDivider />
85+
<ToolbarButton
86+
:title="alignAction.title"
87+
:icon="alignAction.icon"
88+
:is-active="alignAction.isActive"
89+
@click="alignAction.handler"
90+
/>
91+
<ToolbarDivider />
8592
<ToolbarButton
8693
v-for="action in scriptActions"
8794
:key="action.name"
@@ -131,7 +138,8 @@
131138
textFormattingToolbar$,
132139
} = getTipTapEditorStrings();
133140
134-
const { textActions, listActions, scriptActions, insertTools } = useToolbarActions(emit);
141+
const { textActions, listActions, scriptActions, insertTools, alignAction } =
142+
useToolbarActions(emit);
135143
136144
const { canIncreaseFormat, canDecreaseFormat, increaseFormat, decreaseFormat } =
137145
useFormatControls();
@@ -200,6 +208,7 @@
200208
listActions,
201209
scriptActions,
202210
insertTools,
211+
alignAction,
203212
toggleToolbar,
204213
canIncreaseFormat,
205214
canDecreaseFormat,

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Editor } from '@tiptap/vue-2';
33
import StarterKitExtension from '@tiptap/starter-kit';
44
import { Superscript } from '@tiptap/extension-superscript';
55
import { Subscript } from '@tiptap/extension-subscript';
6+
import { TextAlign } from '@tiptap/extension-text-align';
67
import { Small } from '../extensions/SmallTextExtension';
78
import { Image } from '../extensions/Image';
89
import { CodeBlockSyntaxHighlight } from '../extensions/CodeBlockSyntaxHighlight';
@@ -31,6 +32,9 @@ export function useEditor() {
3132
Image,
3233
CustomLink, // Use our custom Link extension
3334
Math,
35+
TextAlign.configure({
36+
types: ['heading', 'paragraph', 'image', 'small'],
37+
}),
3438
],
3539
content: content || '<p></p>',
3640
editorProps: {

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ import { sanitizePastedHTML } from '../utils/markdown';
55
export function useToolbarActions(emit) {
66
const editor = inject('editor', null);
77

8+
// helper
9+
const getEffectiveAlignment = editorInstance => {
10+
if (!editorInstance) return 'left';
11+
12+
const isLeftAligned = editorInstance.isActive({ textAlign: 'left' });
13+
const isRightAligned = editorInstance.isActive({ textAlign: 'right' });
14+
15+
if (isLeftAligned) return 'left';
16+
if (isRightAligned) return 'right';
17+
18+
const { from } = editorInstance.state.selection;
19+
const dom = editorInstance.view.domAtPos(from).node;
20+
const el = dom.nodeType === 1 ? dom : dom.parentElement;
21+
22+
return el ? window.getComputedStyle(el).textAlign : 'left';
23+
};
24+
825
const {
926
undo$,
1027
redo$,
@@ -21,6 +38,8 @@ export function useToolbarActions(emit) {
2138
mathFormula$,
2239
codeBlock$,
2340
clipboardAccessFailed$,
41+
alignLeft$,
42+
alignRight$,
2443
} = getTipTapEditorStrings();
2544

2645
// Action handlers
@@ -181,6 +200,18 @@ export function useToolbarActions(emit) {
181200
}
182201
};
183202

203+
const handleToggleAlign = () => {
204+
if (!editor?.value) return;
205+
206+
const align = getEffectiveAlignment(editor.value);
207+
208+
editor.value
209+
.chain()
210+
.focus()
211+
.setTextAlign(align === 'right' ? 'left' : 'right')
212+
.run();
213+
};
214+
184215
const handleBulletList = () => {
185216
if (editor?.value) {
186217
editor.value.chain().focus().toggleBulletList().run();
@@ -418,6 +449,23 @@ export function useToolbarActions(emit) {
418449
handler: handleMinimize,
419450
};
420451

452+
const alignAction = computed(() => {
453+
const editorInstance = editor?.value;
454+
const effectiveAlign = getEffectiveAlignment(editorInstance);
455+
const effectiveRight = effectiveAlign === 'right';
456+
457+
return {
458+
name: 'toggleAlign',
459+
title: effectiveRight ? alignLeft$() : alignRight$(),
460+
icon: effectiveRight
461+
? require('../../assets/icon-alignLeft.svg')
462+
: require('../../assets/icon-alignRight.svg'),
463+
handler: handleToggleAlign,
464+
isActive: false,
465+
isAvailable: !isMarkActive('codeBlock'),
466+
};
467+
});
468+
421469
return {
422470
// Individual handlers
423471
handleUndo,
@@ -429,6 +477,7 @@ export function useToolbarActions(emit) {
429477
handleCopy,
430478
handlePaste,
431479
handlePasteNoFormat,
480+
handleToggleAlign,
432481
handleBulletList,
433482
handleNumberList,
434483
handleSubscript,
@@ -444,6 +493,7 @@ export function useToolbarActions(emit) {
444493
// Action arrays
445494
historyActions,
446495
textActions,
496+
alignAction,
447497
listActions,
448498
scriptActions,
449499
insertTools,

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ export const Image = Node.create({
1818
alt: { default: null },
1919
width: { default: null },
2020
height: { default: null },
21+
textAlign: {
22+
default: 'left',
23+
parseHTML: element => {
24+
const align = element.style.textAlign || element.getAttribute('data-text-align');
25+
return align || 'left';
26+
},
27+
renderHTML: attributes => {
28+
if (!attributes.textAlign || attributes.textAlign === 'left') {
29+
return {};
30+
}
31+
return { 'data-text-align': attributes.textAlign };
32+
},
33+
},
2134
};
2235
},
2336

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/SmallTextExtension.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export const Small = Node.create({
3838
class: {
3939
default: 'small-text',
4040
},
41+
textAlign: {
42+
default: 'left',
43+
parseHTML: element => element.style.textAlign || 'left',
44+
renderHTML: attributes => {
45+
if (!attributes.textAlign || attributes.textAlign === 'left') return {};
46+
return { style: `text-align: ${attributes.textAlign}` };
47+
},
48+
},
4149
};
4250
},
4351

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import { storageUrl } from '../../../../vuex/file/utils';
55

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

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

16-
const [, alt, fullPath, width, height] = match;
17+
const [, alt, fullPath, width, height, align] = match;
1718

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

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

30-
export const paramsToImageMd = ({ src, alt, width, height, permanentSrc }) => {
31+
export const paramsToImageMd = ({ src, alt, width, height, permanentSrc, textAlign }) => {
3132
const sourceToSave = permanentSrc || src;
3233

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

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

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

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

145148
processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => {

0 commit comments

Comments
 (0)