Skip to content

Commit 0359de5

Browse files
committed
[IMP] style: add text rotation
Add rotation to the text style. Task-id: 5158912
1 parent 89da34d commit 0359de5

File tree

19 files changed

+820
-43
lines changed

19 files changed

+820
-43
lines changed

packages/o-spreadsheet-engine/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export const DEFAULT_STYLE = {
175175
fontSize: 10,
176176
fillColor: "",
177177
textColor: "",
178+
rotation: 0,
178179
} satisfies Required<Style>;
179180

180181
export const DEFAULT_VERTICAL_ALIGN = DEFAULT_STYLE.verticalAlign;

packages/o-spreadsheet-engine/src/helpers/text_helper.ts

Lines changed: 119 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,14 @@ export function getCellContentHeight(
4141
content: string,
4242
style: Style | undefined,
4343
colSize: number
44-
) {
44+
): number {
4545
const maxWidth = style?.wrapping === "wrap" ? colSize - 2 * MIN_CELL_TEXT_MARGIN : undefined;
46-
const numberOfLines = splitTextToWidth(ctx, content, style, maxWidth).length;
46+
const lines = splitTextToWidth(ctx, content, style, maxWidth);
4747
const fontSize = computeTextFontSizeInPixels(style);
48-
return computeTextLinesHeight(fontSize, numberOfLines) + 2 * PADDING_AUTORESIZE_VERTICAL;
48+
if (!style?.rotation) {
49+
return computeTextLinesHeight(fontSize, lines.length) + 2 * PADDING_AUTORESIZE_VERTICAL;
50+
}
51+
return computeMultilineTextSize(ctx, lines, style).height + 2 * PADDING_AUTORESIZE_VERTICAL;
4952
}
5053

5154
export function getDefaultContextFont(
@@ -58,7 +61,38 @@ export function getDefaultContextFont(
5861
return `${italicStr} ${weight} ${fontSize}px ${DEFAULT_FONT}`;
5962
}
6063

61-
const textWidthCache: Record<string, Record<string, number>> = {};
64+
export function computeMultilineTextWidth(
65+
context: Canvas2DContext,
66+
text: string[],
67+
style: Style,
68+
fontUnit: "px" | "pt" = "pt"
69+
) {
70+
const font = computeTextFont(style, fontUnit);
71+
const sizes = text.map((line) => computeCachedTextDimension(context, line, font));
72+
const width = Math.max(...sizes.map((size) => size.width));
73+
if (!style.rotation) return width;
74+
const height = computeTextLinesHeight(sizes[0].height, text.length);
75+
const cos = Math.abs(Math.cos(style.rotation));
76+
const sin = Math.abs(Math.sin(style.rotation));
77+
return width * cos + height * sin;
78+
}
79+
80+
export function computeMultilineTextSize(
81+
context: Canvas2DContext,
82+
text: string[],
83+
style: Style,
84+
fontUnit: "px" | "pt" = "pt"
85+
) {
86+
if (!text.length) return { width: 0, height: 0 };
87+
const font = computeTextFont(style, fontUnit);
88+
const sizes = text.map((line) => computeCachedTextDimension(context, line, font));
89+
const height = computeTextLinesHeight(sizes[0].height, text.length);
90+
const width = Math.max(...sizes.map((size) => size.width));
91+
if (!style.rotation) return { height, width };
92+
const cos = Math.abs(Math.cos(style.rotation));
93+
const sin = Math.abs(Math.sin(style.rotation));
94+
return { width: width * cos + height * sin, height: sin * width + cos * height };
95+
}
6296

6397
export function computeTextWidth(
6498
context: Canvas2DContext,
@@ -67,20 +101,20 @@ export function computeTextWidth(
67101
fontUnit: "px" | "pt" = "pt"
68102
) {
69103
const font = computeTextFont(style, fontUnit);
70-
return computeCachedTextWidth(context, text, font);
104+
return computeCachedTextWidth(context, text, font, style.rotation);
71105
}
72106

73-
export function computeCachedTextWidth(context: Canvas2DContext, text: string, font: string) {
74-
if (!textWidthCache[font]) {
75-
textWidthCache[font] = {};
76-
}
77-
if (textWidthCache[font][text] === undefined) {
78-
const oldFont = context.font;
79-
context.font = font;
80-
textWidthCache[font][text] = context.measureText(text).width;
81-
context.font = oldFont;
82-
}
83-
return textWidthCache[font][text];
107+
export function computeCachedTextWidth(
108+
context: Canvas2DContext,
109+
text: string,
110+
font: string,
111+
rotation?: number
112+
) {
113+
const size = computeCachedTextDimension(context, text, font);
114+
if (!rotation) return size.width;
115+
const cos = Math.abs(Math.cos(rotation));
116+
const sin = Math.abs(Math.sin(rotation));
117+
return size.width * cos + size.height * sin;
84118
}
85119

86120
const textDimensionsCache: Record<string, Record<string, { width: number; height: number }>> = {};
@@ -92,23 +126,29 @@ export function computeTextDimension(
92126
fontUnit: "px" | "pt" = "pt"
93127
): { width: number; height: number } {
94128
const font = computeTextFont(style, fontUnit);
95-
context.save();
96-
context.font = font;
97-
const dimensions = computeCachedTextDimension(context, text);
98-
context.restore();
99-
return dimensions;
129+
const size = computeCachedTextDimension(context, text, font);
130+
if (!style.rotation) return size;
131+
const cos = Math.abs(Math.cos(style.rotation));
132+
const sin = Math.abs(Math.sin(style.rotation));
133+
return {
134+
width: size.width * cos + size.height * sin,
135+
height: size.height * cos + size.width * sin,
136+
};
100137
}
101138

102139
function computeCachedTextDimension(
103140
context: Canvas2DContext,
104-
text: string
141+
text: string,
142+
font: string
105143
): { width: number; height: number } {
106-
const font = context.font;
107144
if (!textDimensionsCache[font]) {
108145
textDimensionsCache[font] = {};
109146
}
110147
if (textDimensionsCache[font][text] === undefined) {
148+
context.save();
149+
context.font = font;
111150
const measure = context.measureText(text);
151+
context.restore();
112152
const width = measure.width;
113153
const height = measure.fontBoundingBoxAscent + measure.fontBoundingBoxDescent;
114154
textDimensionsCache[font][text] = { width, height };
@@ -396,3 +436,59 @@ export function sliceTextToFitWidth(
396436
const slicedText = text.slice(0, Math.max(0, lowerBoundLen - 1));
397437
return slicedText ? slicedText + ellipsis : "";
398438
}
439+
440+
/**
441+
* Return the position to draw text on a rotated canvas to ensure that the rotated text alignment correspond
442+
* with to original's text vertical and horizontal alignment.
443+
*/
444+
export function computeRotationPosition(
445+
rect: { x: number; y: number; textWidth: number; textHeight: number },
446+
style: Style
447+
): PixelPosition {
448+
if (!style.rotation || !(style.rotation % (Math.PI * 2))) return rect;
449+
let { x, y } = rect;
450+
const cos = Math.cos(-style.rotation);
451+
const sin = Math.sin(-style.rotation);
452+
const width = rect.textWidth - MIN_CELL_TEXT_MARGIN;
453+
const height = rect.textHeight;
454+
455+
const center = style.align === "center";
456+
const rotateTowardCellCenter = (style.align === "left") !== sin > 0;
457+
458+
const sh = sin * height;
459+
const sw = Math.abs(sin * width);
460+
const ch = cos * height;
461+
462+
// Adapt the anchor position based on the alignment and rotation
463+
if (style.verticalAlign === "top") {
464+
if (center) {
465+
y += sw / 2;
466+
x -= sh / 2;
467+
} else if (rotateTowardCellCenter) {
468+
x -= sh;
469+
} else {
470+
y += sw;
471+
}
472+
} else if (!style.verticalAlign || style.verticalAlign === "bottom") {
473+
y += height - ch;
474+
if (center) {
475+
y -= sw / 2;
476+
x -= sh / 2;
477+
} else if (rotateTowardCellCenter) {
478+
x -= sh;
479+
y -= sw;
480+
}
481+
} else {
482+
if (center) {
483+
x -= sh / 2;
484+
} else if (rotateTowardCellCenter) {
485+
x -= sh;
486+
y -= sw / 2;
487+
} else {
488+
y += sw / 2 + ch / 4;
489+
}
490+
}
491+
492+
// Return the coordinate in the rotate 2d plane
493+
return { x: cos * x - sin * y, y: cos * y + sin * x };
494+
}

packages/o-spreadsheet-engine/src/plugins/ui_core_views/header_sizes_ui.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ export class HeaderSizeUIPlugin extends CoreViewPlugin<HeaderSizeState> implemen
110110
}
111111
break;
112112
case "SET_FORMATTING":
113-
if (cmd.style && ("fontSize" in cmd.style || "wrapping" in cmd.style)) {
113+
if (
114+
cmd.style &&
115+
("fontSize" in cmd.style || "wrapping" in cmd.style || "rotation" in cmd.style)
116+
) {
114117
for (const zone of cmd.target) {
115118
// TODO FLDA use rangeSet
116119
this.updateRowSizeForZoneChange(cmd.sheetId, zone);

packages/o-spreadsheet-engine/src/plugins/ui_feature/ui_sheet.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { formatValue } from "../../helpers/format/format";
99
import { localizeFormula } from "../../helpers/locale";
1010
import { groupConsecutive, largeMax, range } from "../../helpers/misc";
1111
import {
12+
computeMultilineTextSize,
13+
computeMultilineTextWidth,
1214
computeTextLinesHeight,
1315
computeTextWidth,
1416
getCanvas,
@@ -36,6 +38,7 @@ export class SheetUIPlugin extends UIPlugin {
3638
"getTextWidth",
3739
"getCellText",
3840
"getCellMultiLineText",
41+
"getMultilineTextSize",
3942
"getContiguousZone",
4043
"computeTextYCoordinate",
4144
] as const;
@@ -96,9 +99,7 @@ export class SheetUIPlugin extends UIPlugin {
9699
const content = this.getters.getEvaluatedCell(position).formattedValue;
97100
if (content) {
98101
const multiLineText = splitTextToWidth(this.ctx, content, style, undefined);
99-
contentWidth += Math.max(
100-
...multiLineText.map((line) => computeTextWidth(this.ctx, line, style))
101-
);
102+
contentWidth += computeMultilineTextWidth(this.ctx, multiLineText, style);
102103
}
103104

104105
for (const icon of this.getters.getCellIcons(position)) {
@@ -125,6 +126,10 @@ export class SheetUIPlugin extends UIPlugin {
125126
return computeTextWidth(this.ctx, text, style);
126127
}
127128

129+
getMultilineTextSize(text: string[], style: Style) {
130+
return computeMultilineTextSize(this.ctx, text, style);
131+
}
132+
128133
getCellText(
129134
position: CellPosition,
130135
args?: { showFormula?: boolean; availableWidth?: number }

packages/o-spreadsheet-engine/src/types/misc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export interface Style {
119119
fillColor?: Color;
120120
textColor?: Color;
121121
fontSize?: number; // in pt, not in px!
122+
rotation?: number;
122123
}
123124

124125
export interface DataBarFill {

packages/o-spreadsheet-engine/src/types/rendering.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type Rect = DOMCoordinates & DOMDimension;
2121
export interface BoxTextContent {
2222
textLines: string[];
2323
width: Pixel;
24+
textHeight: Pixel;
25+
textWidth: Pixel;
2426
align: Align;
2527
fontSizePx: number;
2628
x: Pixel;

src/actions/format_actions.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,46 @@ export const formatUnderline: ActionSpec = {
256256
isActive: (env) => !!env.model.getters.getCurrentStyle().underline,
257257
};
258258

259+
export const formatRotation: ActionSpec = {
260+
name: _t("Rotation"),
261+
icon: (env) => getRotationIcon(env),
262+
};
263+
264+
export const formatNoRotation: ActionSpec = {
265+
name: _t("No Rotation"),
266+
execute: (env) => setStyle(env, { rotation: 0 }),
267+
icon: "o-spreadsheet-Icon.ROTATION-0",
268+
isActive: (env) => env.model.getters.getCurrentStyle().rotation === 0,
269+
};
270+
271+
export const formatRotation45: ActionSpec = {
272+
name: _t("45° Rotation"),
273+
execute: (env) => setStyle(env, { rotation: Math.PI / 4 }),
274+
icon: "o-spreadsheet-Icon.ROTATION-45",
275+
isActive: (env) => env.model.getters.getCurrentStyle().rotation === Math.PI / 4,
276+
};
277+
278+
export const formatRotation90: ActionSpec = {
279+
name: _t("90° rotation"),
280+
execute: (env) => setStyle(env, { rotation: Math.PI / 2 }),
281+
icon: "o-spreadsheet-Icon.ROTATION-90",
282+
isActive: (env) => env.model.getters.getCurrentStyle().rotation === Math.PI / 2,
283+
};
284+
285+
export const formatRotation270: ActionSpec = {
286+
name: _t("-90° rotation"),
287+
execute: (env) => setStyle(env, { rotation: -Math.PI / 2 }),
288+
icon: "o-spreadsheet-Icon.ROTATION-270",
289+
isActive: (env) => env.model.getters.getCurrentStyle().rotation === -Math.PI / 2,
290+
};
291+
292+
export const formatRotation315: ActionSpec = {
293+
name: _t("-45° rotation"),
294+
execute: (env) => setStyle(env, { rotation: -Math.PI / 4 }),
295+
icon: "o-spreadsheet-Icon.ROTATION-315",
296+
isActive: (env) => env.model.getters.getCurrentStyle().rotation === -Math.PI / 4,
297+
};
298+
259299
export const formatStrikethrough: ActionSpec = {
260300
name: _t("Strikethrough"),
261301
execute: (env) =>
@@ -449,6 +489,14 @@ function getWrappingMode(env: SpreadsheetChildEnv): Wrapping {
449489
return DEFAULT_WRAPPING_MODE;
450490
}
451491

492+
function getRotation(env: SpreadsheetChildEnv): number {
493+
const style = env.model.getters.getCurrentStyle();
494+
if (style.rotation) {
495+
return style.rotation;
496+
}
497+
return 0;
498+
}
499+
452500
function getHorizontalAlignmentIcon(env: SpreadsheetChildEnv) {
453501
const horizontalAlign = getHorizontalAlign(env);
454502

@@ -487,3 +535,20 @@ function getWrapModeIcon(env: SpreadsheetChildEnv) {
487535
return "o-spreadsheet-Icon.WRAPPING_OVERFLOW";
488536
}
489537
}
538+
539+
function getRotationIcon(env: SpreadsheetChildEnv) {
540+
const rotation = getRotation(env);
541+
542+
switch (rotation) {
543+
case Math.PI / 2:
544+
return "o-spreadsheet-Icon.ROTATION-90";
545+
case -Math.PI / 2:
546+
return "o-spreadsheet-Icon.ROTATION-270";
547+
case Math.PI / 4:
548+
return "o-spreadsheet-Icon.ROTATION-45";
549+
case -Math.PI / 4:
550+
return "o-spreadsheet-Icon.ROTATION-315";
551+
default:
552+
return "o-spreadsheet-Icon.ROTATION-0";
553+
}
554+
}

src/components/icons/icons.xml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,4 +1048,44 @@
10481048
/>
10491049
</svg>
10501050
</t>
1051+
<t t-name="o-spreadsheet-Icon.ROTATION-0">
1052+
<svg
1053+
width="18"
1054+
height="18"
1055+
viewBox="0 0 18 18"
1056+
transform="rotate(270)"
1057+
xmlns="http://www.w3.org/2000/svg">
1058+
<path
1059+
d="M5 2h1v12h1.5l-2 2-2-2H5m6-5h1v5h1.5l-2 2-2-2H11M8 2l7 2.8V6L8 8.8l-.43-1.12 1.9-.7V3.8l-1.9-.7L8 1.98m2.7 2.25v2.3l2.8-1.1z"
1060+
/>
1061+
</svg>
1062+
</t>
1063+
<t t-name="o-spreadsheet-Icon.ROTATION-45">
1064+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
1065+
<path
1066+
d="m1.95 6.879.707-.707 8.485 8.485 1.06-1.06v2.828H9.375l1.061-1.061m.706-7.778.707-.707 3.536 3.535 1.06-1.06v2.828h-2.828l1.06-1.06M4.071 4.757l6.93-2.97.848.849-2.97 6.93-1.096-.488.849-1.839-2.249-2.248-1.838.848-.488-1.096m3.5-.318 1.626 1.626 1.203-2.757z"
1067+
/>
1068+
</svg>
1069+
</t>
1070+
<t t-name="o-spreadsheet-Icon.ROTATION-90">
1071+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
1072+
<path
1073+
d="M5 2h1v12h1.5l-2 2-2-2H5m6-5h1v5h1.5l-2 2-2-2H11M8 2l7 2.8V6L8 8.8l-.43-1.12 1.9-.7V3.8l-1.9-.7L8 1.98m2.7 2.25v2.3l2.8-1.1z"
1074+
/>
1075+
</svg>
1076+
</t>
1077+
<t t-name="o-spreadsheet-Icon.ROTATION-270">
1078+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
1079+
<path
1080+
d="M13 16h-1V4h-1.5l2-2 2 2H13M7 9H6V4H4.5l2-2 2 2H7m3 12-7-2.8V12l7-2.8.43 1.12-1.9.7v3.18l1.9.7-.43 1.12m-2.7-2.25v-2.3l-2.8 1.1z"
1081+
/>
1082+
</svg>
1083+
</t>
1084+
<t t-name="o-spreadsheet-Icon.ROTATION-315">
1085+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
1086+
<path
1087+
d="m6.879 16.55-.707-.707 8.485-8.485-1.06-1.06h2.828v2.828l-1.061-1.061m-7.778-.707-.707-.707 3.535-3.536-1.06-1.06h2.828v2.828l-1.06-1.06M4.757 14.429l-2.97-6.93.849-.848 6.93 2.97-.488 1.096-1.839-.849-2.248 2.249.848 1.838-1.096.488m-.318-3.5 1.626-1.626-2.757-1.203z"
1088+
/>
1089+
</svg>
1090+
</t>
10511091
</templates>

0 commit comments

Comments
 (0)