Skip to content

Commit de4b4ef

Browse files
committed
feat(composer): align images in the editor and in sent messages
Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
1 parent 9b53c03 commit de4b4ef

4 files changed

Lines changed: 88 additions & 3 deletions

File tree

src/ckeditor/image/ImageDowncastPlugin.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ import type { ViewDocumentFragment, ViewElement } from 'ckeditor5'
77

88
import { ImageUtils, Plugin, UpcastWriter } from 'ckeditor5'
99

10+
/**
11+
* Alignment as inline styles, keyed by the class the image style feature writes.
12+
*
13+
* Physical margins because the Word engine behind Outlook ignores margin-inline.
14+
* The margins place a figure narrower than the available space, text-align
15+
* places the image inside a full width figure.
16+
*/
17+
const ALIGNMENTS: Record<string, Record<string, string>> = {
18+
'image-style-align-center': { 'margin-left': 'auto', 'margin-right': 'auto', 'text-align': 'center' },
19+
'image-style-block-align-right': { 'margin-left': 'auto', 'margin-right': '0', 'text-align': 'right' },
20+
'image-style-block-align-left': { 'margin-left': '0', 'margin-right': 'auto', 'text-align': 'left' },
21+
}
22+
1023
/**
1124
* Parse a CSS length into whole pixels. Anything but an absolute pixel value
1225
* yields null.
@@ -52,12 +65,29 @@ export default class ImageDowncastPlugin extends Plugin {
5265
// A block image carries the resized width on its figure, an inline
5366
// one on the img itself.
5467
if ((item.is('element', 'figure') && item.hasClass('image')) || item.is('element', 'img')) {
68+
this._inlineAlignment(writer, item)
5569
this._mirrorResizedWidth(writer, item)
5670
}
5771
}
5872
}, { priority: 'low' })
5973
}
6074

75+
/**
76+
* Adds the inline styles for the figure's alignment class, which is backed by
77+
* an editor stylesheet recipients never load. The class stays so that
78+
* reopening the draft restores the active alignment.
79+
*
80+
* @param writer view writer of the data view
81+
* @param figure the figure to align
82+
*/
83+
_inlineAlignment(writer: UpcastWriter, figure: ViewElement): void {
84+
// Without an alignment class the figure keeps the client's own margins.
85+
const className = Object.keys(ALIGNMENTS).find((candidate) => figure.hasClass(candidate))
86+
?? 'image-style-block-align-left'
87+
88+
writer.setStyle(ALIGNMENTS[className], figure)
89+
}
90+
6191
/**
6292
* Mirrors a resized image's CSS width onto the img width attribute, which
6393
* clients that drop CSS still honour. Reopening the message reads that width

src/components/TextEditor.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import {
3939
Heading,
4040
Image,
4141
ImageResize,
42+
ImageStyle,
43+
ImageToolbar,
4244
ImageUpload,
4345
Italic,
4446
Link,
@@ -152,6 +154,8 @@ export default {
152154
Image,
153155
ImageUpload,
154156
ImageResize,
157+
ImageStyle,
158+
ImageToolbar,
155159
FilesImagePlugin,
156160
ImageDowncastPlugin,
157161
Font,
@@ -206,6 +210,15 @@ export default {
206210
image: {
207211
// A percentage would be relative to the recipient's unknown viewport.
208212
resizeUnit: 'px',
213+
styles: {
214+
options: ['alignBlockLeft', 'alignCenter', 'alignBlockRight'],
215+
},
216+
217+
toolbar: [
218+
'imageStyle:alignBlockLeft',
219+
'imageStyle:alignCenter',
220+
'imageStyle:alignBlockRight',
221+
],
209222
},
210223
211224
mention: {
@@ -718,6 +731,12 @@ export default {
718731
cursor: text;
719732
margin: 0 !important;
720733
}
734+
735+
/* CKEditor centres a block image without a style class; match the left aligned
736+
option, which is what ImageDowncast writes for it. */
737+
:deep(.ck-content .image:not([class*='image-style'])) {
738+
margin-inline: 0 auto;
739+
}
721740
</style>
722741
723742
<style>

src/tests/unit/ckeditor/image/ImageDowncastPlugin.spec.js

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { ClassicEditor, ImageBlock, ImageInline, ImageResizeEditing, Paragraph } from 'ckeditor5'
6+
import { ClassicEditor, ImageBlock, ImageInline, ImageResizeEditing, ImageStyleEditing, Paragraph } from 'ckeditor5'
77
import ImageDowncastPlugin from '../../../../ckeditor/image/ImageDowncastPlugin.ts'
88

99
// The editor UI observes the size of its toolbar, which jsdom does not provide.
@@ -26,9 +26,12 @@ async function downcast(initialData) {
2626
const editor = await ClassicEditor.create(element, {
2727
licenseKey: 'GPL',
2828
initialData,
29-
plugins: [Paragraph, ImageBlock, ImageInline, ImageResizeEditing, ImageDowncastPlugin],
29+
plugins: [Paragraph, ImageBlock, ImageInline, ImageResizeEditing, ImageStyleEditing, ImageDowncastPlugin],
3030
image: {
3131
resizeUnit: 'px',
32+
styles: {
33+
options: ['alignBlockLeft', 'alignCenter', 'alignBlockRight'],
34+
},
3235
},
3336
})
3437

@@ -109,4 +112,36 @@ describe('ImageDowncastPlugin', () => {
109112
expect(resent).toContain('width:200px;')
110113
expect(resent).not.toContain('height=')
111114
})
115+
116+
it('inlines the styles of a centred image', async () => {
117+
const data = await downcast('<figure class="image image-style-align-center"><img src="test.png"></figure>')
118+
119+
expect(data).toContain('margin-left:auto;')
120+
expect(data).toContain('margin-right:auto;')
121+
expect(data).toContain('text-align:center;')
122+
expect(data).toContain('image-style-align-center')
123+
})
124+
125+
it('inlines the styles of a right aligned image', async () => {
126+
const data = await downcast('<figure class="image image-style-block-align-right"><img src="test.png"></figure>')
127+
128+
expect(data).toContain('margin-left:auto;')
129+
expect(data).toContain('margin-right:0;')
130+
expect(data).toContain('text-align:right;')
131+
})
132+
133+
it('left aligns an image without a style class', async () => {
134+
const data = await downcast('<figure class="image"><img src="test.png"></figure>')
135+
136+
expect(data).toContain('margin-left:0;')
137+
expect(data).toContain('margin-right:auto;')
138+
expect(data).toContain('text-align:left;')
139+
})
140+
141+
it('keeps the alignment when the message is reopened and sent again', async () => {
142+
const sent = await downcast('<figure class="image image-style-align-center"><img src="test.png"></figure>')
143+
const resent = await downcast(sent)
144+
145+
expect(resent).toContain('text-align:center;')
146+
})
112147
})

src/tests/unit/components/TextEditor.spec.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('TextEditor', () => {
5757
expect(wrapper.vm.config.htmlSupport.allow.some((rule) => rule.name === 'img')).toBe(true)
5858
})
5959

60-
it('resizes images in pixels in html mode', async () => {
60+
it('aligns and resizes images in html mode', async () => {
6161
const wrapper = shallowMount(TextEditor, {
6262
localVue,
6363
provide: {
@@ -72,6 +72,7 @@ describe('TextEditor', () => {
7272

7373
expect(wrapper.vm.config.plugins).toContain(ImageDowncastPlugin)
7474
expect(wrapper.vm.config.image.resizeUnit).toBe('px')
75+
expect(wrapper.vm.config.image.toolbar).toContain('imageStyle:alignCenter')
7576
})
7677

7778
it('throw when editor not ready', async () => {

0 commit comments

Comments
 (0)