Skip to content

Commit bb3026e

Browse files
committed
[IMP] style: add text rotation
Add rotation to the text style. Task-id: 5158912
1 parent 9749220 commit bb3026e

File tree

19 files changed

+798
-44
lines changed

19 files changed

+798
-44
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export const DEFAULT_STYLE = {
178178
fontSize: 10,
179179
fillColor: "",
180180
textColor: "",
181+
rotation: 0,
181182
} satisfies Required<Style>;
182183

183184
export const DEFAULT_VERTICAL_ALIGN = DEFAULT_STYLE.verticalAlign;

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

Lines changed: 108 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,10 @@ 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;
47-
const fontSize = computeTextFontSizeInPixels(style);
48-
return computeTextLinesHeight(fontSize, numberOfLines) + 2 * PADDING_AUTORESIZE_VERTICAL;
46+
const lines = splitTextToWidth(ctx, content, style, maxWidth);
47+
return computeMultilineTextSize(ctx, lines, style).height + 2 * PADDING_AUTORESIZE_VERTICAL;
4948
}
5049

5150
export function getDefaultContextFont(
@@ -58,29 +57,48 @@ export function getDefaultContextFont(
5857
return `${italicStr} ${weight} ${fontSize}px ${DEFAULT_FONT}`;
5958
}
6059

61-
const textWidthCache: Record<string, Record<string, number>> = {};
60+
export function computeMultilineTextSize(
61+
context: Canvas2DContext,
62+
textLines: string[],
63+
style: Style = {},
64+
fontUnit: "px" | "pt" = "pt"
65+
) {
66+
if (!textLines.length) return { width: 0, height: 0 };
67+
const font = computeTextFont(style, fontUnit);
68+
const sizes = textLines.map((line) => computeCachedTextDimension(context, line, font));
69+
const height = computeTextLinesHeight(sizes[0].height, textLines.length);
70+
const width = Math.max(...sizes.map((size) => size.width));
71+
if (!style.rotation) {
72+
return { height, width };
73+
}
74+
const cos = Math.abs(Math.cos(style.rotation));
75+
const sin = Math.abs(Math.sin(style.rotation));
76+
return { width: width * cos + height * sin, height: sin * width + cos * height };
77+
}
6278

6379
export function computeTextWidth(
6480
context: Canvas2DContext,
6581
text: string,
66-
style: Style,
82+
style: Style = {},
6783
fontUnit: "px" | "pt" = "pt"
6884
) {
6985
const font = computeTextFont(style, fontUnit);
70-
return computeCachedTextWidth(context, text, font);
86+
return computeCachedTextWidth(context, text, font, style.rotation);
7187
}
7288

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;
89+
function computeCachedTextWidth(
90+
context: Canvas2DContext,
91+
text: string,
92+
font: string,
93+
rotation?: number
94+
) {
95+
const size = computeCachedTextDimension(context, text, font);
96+
if (!rotation) {
97+
return size.width;
8298
}
83-
return textWidthCache[font][text];
99+
const cos = Math.abs(Math.cos(rotation));
100+
const sin = Math.abs(Math.sin(rotation));
101+
return size.width * cos + size.height * sin;
84102
}
85103

86104
const textDimensionsCache: Record<string, Record<string, { width: number; height: number }>> = {};
@@ -92,23 +110,31 @@ export function computeTextDimension(
92110
fontUnit: "px" | "pt" = "pt"
93111
): { width: number; height: number } {
94112
const font = computeTextFont(style, fontUnit);
95-
context.save();
96-
context.font = font;
97-
const dimensions = computeCachedTextDimension(context, text);
98-
context.restore();
99-
return dimensions;
113+
const size = computeCachedTextDimension(context, text, font);
114+
if (!style.rotation) {
115+
return size;
116+
}
117+
const cos = Math.abs(Math.cos(style.rotation));
118+
const sin = Math.abs(Math.sin(style.rotation));
119+
return {
120+
width: size.width * cos + size.height * sin,
121+
height: size.height * cos + size.width * sin,
122+
};
100123
}
101124

102125
function computeCachedTextDimension(
103126
context: Canvas2DContext,
104-
text: string
127+
text: string,
128+
font: string
105129
): { width: number; height: number } {
106-
const font = context.font;
107130
if (!textDimensionsCache[font]) {
108131
textDimensionsCache[font] = {};
109132
}
110133
if (textDimensionsCache[font][text] === undefined) {
134+
context.save();
135+
context.font = font;
111136
const measure = context.measureText(text);
137+
context.restore();
112138
const width = measure.width;
113139
const height = measure.fontBoundingBoxAscent + measure.fontBoundingBoxDescent;
114140
textDimensionsCache[font][text] = { width, height };
@@ -396,3 +422,61 @@ export function sliceTextToFitWidth(
396422
const slicedText = text.slice(0, Math.max(0, lowerBoundLen - 1));
397423
return slicedText ? slicedText + ellipsis : "";
398424
}
425+
426+
/**
427+
* Return the position to draw text on a rotated canvas to ensure that the rotated text alignment correspond
428+
* with to original's text vertical and horizontal alignment.
429+
*/
430+
export function computeRotationPosition(
431+
rect: { x: number; y: number; textWidth: number; textHeight: number },
432+
style: Style
433+
): PixelPosition {
434+
if (!style.rotation || style.rotation % (Math.PI * 2) === 0) {
435+
return rect;
436+
}
437+
let { x, y } = rect; // top-left when align=left and top-right when align=right, top-center when align=center
438+
const cos = Math.cos(-style.rotation);
439+
const sin = Math.sin(-style.rotation);
440+
const width = rect.textWidth - MIN_CELL_TEXT_MARGIN;
441+
const height = rect.textHeight;
442+
443+
const center = style.align === "center";
444+
const rotateTowardCellCenter = (style.align === "left") === sin < 0;
445+
446+
const sh = sin * height;
447+
const sw = Math.abs(sin * width);
448+
const ch = cos * height;
449+
450+
// Adapt the anchor position based on the alignment and rotation
451+
if (style.verticalAlign === "top") {
452+
if (center) {
453+
y += sw / 2;
454+
x -= sh / 2;
455+
} else if (rotateTowardCellCenter) {
456+
x -= sh;
457+
} else {
458+
y += sw;
459+
}
460+
} else if (!style.verticalAlign || style.verticalAlign === "bottom") {
461+
y += height - ch;
462+
if (center) {
463+
y -= sw / 2;
464+
x -= sh / 2;
465+
} else if (rotateTowardCellCenter) {
466+
x -= sh;
467+
y -= sw;
468+
}
469+
} else {
470+
if (center) {
471+
x -= sh / 2;
472+
} else if (rotateTowardCellCenter) {
473+
x -= sh;
474+
y -= sw / 2;
475+
} else {
476+
y += sw / 2 + ch / 4;
477+
}
478+
}
479+
480+
// Return the coordinate in the rotate 2d plane
481+
return { x: cos * x - sin * y, y: cos * y + sin * x };
482+
}

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: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { formatValue } from "../../helpers/format/format";
99
import { localizeFormula } from "../../helpers/locale";
1010
import { groupConsecutive, largeMax, range } from "../../helpers/misc";
1111
import {
12+
computeMultilineTextSize,
1213
computeTextLinesHeight,
1314
computeTextWidth,
1415
getCanvas,
@@ -36,6 +37,7 @@ export class SheetUIPlugin extends UIPlugin {
3637
"getTextWidth",
3738
"getCellText",
3839
"getCellMultiLineText",
40+
"getMultilineTextSize",
3941
"getContiguousZone",
4042
"computeTextYCoordinate",
4143
] as const;
@@ -96,9 +98,7 @@ export class SheetUIPlugin extends UIPlugin {
9698
const content = this.getters.getEvaluatedCell(position).formattedValue;
9799
if (content) {
98100
const multiLineText = splitTextToWidth(this.ctx, content, style, undefined);
99-
contentWidth += Math.max(
100-
...multiLineText.map((line) => computeTextWidth(this.ctx, line, style))
101-
);
101+
contentWidth += computeMultilineTextSize(this.ctx, multiLineText, style).width;
102102
}
103103

104104
for (const icon of this.getters.getCellIcons(position)) {
@@ -125,6 +125,10 @@ export class SheetUIPlugin extends UIPlugin {
125125
return computeTextWidth(this.ctx, text, style);
126126
}
127127

128+
getMultilineTextSize(text: string[], style: Style) {
129+
return computeMultilineTextSize(this.ctx, text, style);
130+
}
131+
128132
getCellText(
129133
position: CellPosition,
130134
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; // in rad, clockwise because y+ is down
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: 62 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,11 @@ 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+
return style.rotation ?? 0;
495+
}
496+
452497
function getHorizontalAlignmentIcon(env: SpreadsheetChildEnv) {
453498
const horizontalAlign = getHorizontalAlign(env);
454499

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

src/components/icons/icons.xml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,4 +1059,44 @@
10591059
<i class="fa fa-moon-o"/>
10601060
</div>
10611061
</t>
1062+
<t t-name="o-spreadsheet-Icon.ROTATION-0">
1063+
<svg
1064+
width="18"
1065+
height="18"
1066+
viewBox="0 0 18 18"
1067+
transform="rotate(270)"
1068+
xmlns="http://www.w3.org/2000/svg">
1069+
<path
1070+
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"
1071+
/>
1072+
</svg>
1073+
</t>
1074+
<t t-name="o-spreadsheet-Icon.ROTATION-45">
1075+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
1076+
<path
1077+
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"
1078+
/>
1079+
</svg>
1080+
</t>
1081+
<t t-name="o-spreadsheet-Icon.ROTATION-90">
1082+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
1083+
<path
1084+
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"
1085+
/>
1086+
</svg>
1087+
</t>
1088+
<t t-name="o-spreadsheet-Icon.ROTATION-270">
1089+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
1090+
<path
1091+
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"
1092+
/>
1093+
</svg>
1094+
</t>
1095+
<t t-name="o-spreadsheet-Icon.ROTATION-315">
1096+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
1097+
<path
1098+
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"
1099+
/>
1100+
</svg>
1101+
</t>
10621102
</templates>

0 commit comments

Comments
 (0)