email-builder-utils 1.1.46 → 1.1.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/utils/blocks/button.d.ts +29 -0
- package/dist/utils/blocks/button.d.ts.map +1 -0
- package/dist/utils/blocks/button.js +137 -0
- package/dist/utils/blocks/dividers.d.ts +4 -0
- package/dist/utils/blocks/dividers.d.ts.map +1 -0
- package/dist/utils/blocks/dividers.js +71 -0
- package/dist/utils/blocks/grid.d.ts +6 -0
- package/dist/utils/blocks/grid.d.ts.map +1 -0
- package/dist/utils/blocks/grid.js +248 -0
- package/dist/utils/blocks/image.d.ts +8 -0
- package/dist/utils/blocks/image.d.ts.map +1 -0
- package/dist/utils/blocks/image.js +58 -0
- package/dist/utils/blocks/shape.d.ts +2 -0
- package/dist/utils/blocks/shape.d.ts.map +1 -0
- package/dist/utils/blocks/shape.js +199 -0
- package/dist/utils/blocks/text.d.ts +2 -0
- package/dist/utils/blocks/text.d.ts.map +1 -0
- package/dist/utils/blocks/text.js +106 -0
- package/dist/utils/blocks/video.d.ts +2 -0
- package/dist/utils/blocks/video.d.ts.map +1 -0
- package/dist/utils/blocks/video.js +119 -0
- package/dist/utils/buildStyles.d.ts +10 -0
- package/dist/utils/buildStyles.d.ts.map +1 -0
- package/dist/utils/buildStyles.js +101 -0
- package/dist/utils/gradientUtils.d.ts +8 -0
- package/dist/utils/gradientUtils.d.ts.map +1 -0
- package/dist/utils/gradientUtils.js +68 -0
- package/dist/utils/jsonToHTML.d.ts +2 -29
- package/dist/utils/jsonToHTML.d.ts.map +1 -1
- package/dist/utils/jsonToHTML.js +18 -1560
- package/dist/utils/outlookSupport.d.ts +4 -207
- package/dist/utils/outlookSupport.d.ts.map +1 -1
- package/dist/utils/outlookSupport.js +86 -453
- package/package.json +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.convertShapeBlock = convertShapeBlock;
|
|
4
|
+
const common_1 = require("../common");
|
|
5
|
+
function computeArcSize(borderRadius, widthPx) {
|
|
6
|
+
if (!borderRadius)
|
|
7
|
+
return "0";
|
|
8
|
+
if (typeof borderRadius === "number")
|
|
9
|
+
return Math.min(borderRadius / widthPx, 1).toFixed(2);
|
|
10
|
+
const s = borderRadius.toString().trim();
|
|
11
|
+
if (s.endsWith("%"))
|
|
12
|
+
return Math.min((parseFloat(s.replace("%", "")) || 0) / 100, 1).toFixed(2);
|
|
13
|
+
return Math.min((parseFloat(s.replace("px", "")) || 0) / widthPx, 1).toFixed(2);
|
|
14
|
+
}
|
|
15
|
+
function buildVMLShape({ shape, widthPx, heightPx, imageUrl, backgroundColor, borderWidth, borderColor, borderRadius, text, textColor = "#000000", textSize = 14, verticalAlign = "middle", textAlign = "center", msoHasBakedText = false, }) {
|
|
16
|
+
const bw = borderWidth || 0;
|
|
17
|
+
const bc = borderColor || "transparent";
|
|
18
|
+
const borderAttrs = bw > 0 ? `strokeweight="${bw}px" strokecolor="${bc}"` : `stroked="false"`;
|
|
19
|
+
const fillColor = backgroundColor || "#2F80ED";
|
|
20
|
+
const fillMarkup = `<v:fill ${imageUrl ? `src="${imageUrl}" type="frame" aspect="atleast"` : ""} color="${fillColor}" />`;
|
|
21
|
+
let tag = "rect";
|
|
22
|
+
let extraAttr = "";
|
|
23
|
+
if (shape === "circle" || shape === "oval") {
|
|
24
|
+
tag = "oval";
|
|
25
|
+
}
|
|
26
|
+
else if (shape === "rounded") {
|
|
27
|
+
tag = "roundrect";
|
|
28
|
+
extraAttr = `arcsize="${computeArcSize(borderRadius, widthPx)}"`;
|
|
29
|
+
}
|
|
30
|
+
const vAlignMap = { top: "top", middle: "middle", bottom: "bottom" };
|
|
31
|
+
const hAlignMap = { left: "left", center: "center", right: "right", justify: "left" };
|
|
32
|
+
const vAlign = vAlignMap[verticalAlign] || "middle";
|
|
33
|
+
const hAlign = hAlignMap[textAlign] || "center";
|
|
34
|
+
const safeFontSize = Math.max(Math.round(textSize), 10);
|
|
35
|
+
const textboxMarkup = text && !msoHasBakedText
|
|
36
|
+
? `<v:textbox inset="6pt,6pt,6pt,6pt" style="mso-fit-shape-to-text:false;">
|
|
37
|
+
<div style="display:table;width:100%;height:100%;">
|
|
38
|
+
<div style="display:table-cell;vertical-align:${vAlign};text-align:${hAlign};padding:0 6px;">
|
|
39
|
+
<div style="color:${textColor};font-family:Arial, sans-serif;font-size:${safeFontSize}px;line-height:1.3;word-wrap:break-word;">
|
|
40
|
+
${text}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</v:textbox>`
|
|
45
|
+
: `<v:textbox inset="0,0,0,0"><div style="display:none;">.</div></v:textbox>`;
|
|
46
|
+
return `
|
|
47
|
+
<v:${tag} xmlns:v="urn:schemas-microsoft-com:vml"
|
|
48
|
+
style="width:${widthPx}px;height:${heightPx}px;display:inline-block;"
|
|
49
|
+
${borderAttrs}
|
|
50
|
+
fill="true" fillcolor="${fillColor}"${extraAttr}>
|
|
51
|
+
${fillMarkup}
|
|
52
|
+
${textboxMarkup}
|
|
53
|
+
</v:${tag}>`;
|
|
54
|
+
}
|
|
55
|
+
function appendOutlookForShape(content, outerContainerWidth, innerContainerWidth, opts, visibilityClass) {
|
|
56
|
+
const widthPx = Math.round(Math.min(outerContainerWidth, innerContainerWidth));
|
|
57
|
+
const heightPx = Math.max(1, Math.round(opts.heightPx));
|
|
58
|
+
const vml = buildVMLShape({
|
|
59
|
+
shape: opts.shape, widthPx, heightPx,
|
|
60
|
+
imageUrl: opts.msoBakeImageWithText || opts.imageUrl,
|
|
61
|
+
backgroundColor: opts.shapeColor || opts.backgroundColor,
|
|
62
|
+
borderWidth: opts.borderWidth, borderColor: opts.borderColor,
|
|
63
|
+
borderRadius: opts.borderRadius, text: opts.text,
|
|
64
|
+
textColor: opts.textColor, textSize: opts.textSize,
|
|
65
|
+
verticalAlign: opts.verticalAlign, textAlign: opts.textAlign,
|
|
66
|
+
msoHasBakedText: Boolean(opts.msoBakeImageWithText),
|
|
67
|
+
});
|
|
68
|
+
const pad = opts.padding || {};
|
|
69
|
+
const align = opts.alignment || "left";
|
|
70
|
+
const valign = opts.verticalAlign || "middle";
|
|
71
|
+
const shouldHideInOutlook = visibilityClass.includes("hide-desktop");
|
|
72
|
+
if (shouldHideInOutlook) {
|
|
73
|
+
return `<!--[if !mso]><!-->
|
|
74
|
+
<table align="${align}" border="0" cellpadding="0" cellspacing="0"
|
|
75
|
+
style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
|
|
76
|
+
<tr>
|
|
77
|
+
<td valign="${valign}"
|
|
78
|
+
style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
|
|
79
|
+
${vml}
|
|
80
|
+
</td>
|
|
81
|
+
</tr>
|
|
82
|
+
</table>
|
|
83
|
+
<!--<![endif]-->`;
|
|
84
|
+
}
|
|
85
|
+
return `<!--[if mso]>
|
|
86
|
+
<table align="${align}" border="0" cellpadding="0" cellspacing="0"
|
|
87
|
+
style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
|
|
88
|
+
<tr>
|
|
89
|
+
<td valign="${valign}"
|
|
90
|
+
style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
|
|
91
|
+
${vml}
|
|
92
|
+
</td>
|
|
93
|
+
</tr>
|
|
94
|
+
</table>
|
|
95
|
+
<![endif]-->`;
|
|
96
|
+
}
|
|
97
|
+
async function convertShapeBlock(blockData) {
|
|
98
|
+
const { style, props } = blockData.data;
|
|
99
|
+
const { shape, text, imageUrl } = props;
|
|
100
|
+
const visibilityClass = (0, common_1.getVisibilityClass)(props);
|
|
101
|
+
const { width = "100", height = "150", padding = {}, backgroundColor = "#2F80ED", borderRadius, borderWidth = 0, borderStyle = "solid", borderColor = "transparent", customCss, shapeColor, alignment = "left", msoBakeImageWithText, color = "#000000", fontSize = 14, textAlign = "center", verticalAlign = "middle", } = style || {};
|
|
102
|
+
const borderRadiusMap = {
|
|
103
|
+
rectangle: "0", rounded: "10px", circle: "50%", oval: "50%",
|
|
104
|
+
};
|
|
105
|
+
let resolvedBorderRadius = borderRadius || borderRadiusMap[shape] || "0";
|
|
106
|
+
let resolvedWidthPx = typeof width === "number" ? width : parseInt(width.toString().replace("px", ""), 10) || 100;
|
|
107
|
+
let resolvedHeightPx = typeof height === "number" ? height : parseInt(height.toString().replace("px", ""), 10) || 150;
|
|
108
|
+
if (shape === "circle") {
|
|
109
|
+
const side = Math.min(resolvedWidthPx, resolvedHeightPx);
|
|
110
|
+
resolvedWidthPx = side;
|
|
111
|
+
resolvedHeightPx = side;
|
|
112
|
+
resolvedBorderRadius = "50%";
|
|
113
|
+
}
|
|
114
|
+
else if (shape === "oval") {
|
|
115
|
+
resolvedBorderRadius = "50% / 50%";
|
|
116
|
+
}
|
|
117
|
+
const finalBackgroundColor = shapeColor || backgroundColor;
|
|
118
|
+
const alignmentStyles = {
|
|
119
|
+
left: "margin-right:auto;margin-left:0;",
|
|
120
|
+
center: "margin-left:auto;margin-right:auto;",
|
|
121
|
+
right: "margin-left:auto;margin-right:0;",
|
|
122
|
+
};
|
|
123
|
+
const alignmentStyle = alignmentStyles[alignment] || "";
|
|
124
|
+
const textAlignMap = { left: "left", center: "center", right: "right", justify: "justify" };
|
|
125
|
+
const textAlignStyle = textAlignMap[textAlign] || "center";
|
|
126
|
+
const textSizeStyle = `font-size:${fontSize}px;line-height:1.3;word-break:break-word;overflow-wrap:break-word;color:${color};`;
|
|
127
|
+
let nonMsoContent = "";
|
|
128
|
+
if (imageUrl && text) {
|
|
129
|
+
nonMsoContent = `
|
|
130
|
+
<div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
|
|
131
|
+
border:${borderWidth}px ${borderStyle} ${borderColor};
|
|
132
|
+
border-radius:${resolvedBorderRadius};
|
|
133
|
+
background-color:${finalBackgroundColor};
|
|
134
|
+
background-image:url('${imageUrl}');
|
|
135
|
+
background-position:center center;
|
|
136
|
+
background-size:cover;
|
|
137
|
+
background-repeat:no-repeat;
|
|
138
|
+
overflow:hidden;${alignmentStyle}${customCss || ""}">
|
|
139
|
+
<table border="0" cellpadding="0" cellspacing="0" width="${resolvedWidthPx}"
|
|
140
|
+
style="width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;border-collapse:collapse;">
|
|
141
|
+
<tr>
|
|
142
|
+
<td align="${textAlignStyle}" valign="${verticalAlign}"
|
|
143
|
+
width="${resolvedWidthPx}" height="${resolvedHeightPx}"
|
|
144
|
+
style="padding:6px;vertical-align:${verticalAlign};text-align:${textAlignStyle};overflow:hidden;box-sizing:border-box;">
|
|
145
|
+
<div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">${text}</div>
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
</table>
|
|
149
|
+
</div>`;
|
|
150
|
+
}
|
|
151
|
+
else if (imageUrl) {
|
|
152
|
+
nonMsoContent = `
|
|
153
|
+
<div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
|
|
154
|
+
border:${borderWidth}px ${borderStyle} ${borderColor};
|
|
155
|
+
border-radius:${resolvedBorderRadius};
|
|
156
|
+
overflow:hidden;${alignmentStyle}${customCss || ""}">
|
|
157
|
+
<img src="${imageUrl}" alt="${text || "shape image"}"
|
|
158
|
+
width="${resolvedWidthPx}" height="${resolvedHeightPx}"
|
|
159
|
+
style="width:100%;height:100%;object-fit:cover;border-radius:${resolvedBorderRadius};display:block;" />
|
|
160
|
+
</div>`;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
nonMsoContent = `
|
|
164
|
+
<div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
|
|
165
|
+
background-color:${finalBackgroundColor};
|
|
166
|
+
border:${borderWidth}px ${borderStyle} ${borderColor};
|
|
167
|
+
border-radius:${resolvedBorderRadius};
|
|
168
|
+
overflow:hidden;${alignmentStyle}${customCss || ""}">
|
|
169
|
+
<table border="0" cellpadding="0" cellspacing="0" width="${resolvedWidthPx}"
|
|
170
|
+
style="width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;border-collapse:collapse;">
|
|
171
|
+
<tr>
|
|
172
|
+
<td align="${textAlignStyle}" valign="${verticalAlign}"
|
|
173
|
+
width="${resolvedWidthPx}" height="${resolvedHeightPx}"
|
|
174
|
+
style="padding:8px;vertical-align:${verticalAlign};text-align:${textAlignStyle};box-sizing:border-box;">
|
|
175
|
+
<div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">${text || ""}</div>
|
|
176
|
+
</td>
|
|
177
|
+
</tr>
|
|
178
|
+
</table>
|
|
179
|
+
</div>`;
|
|
180
|
+
}
|
|
181
|
+
const outlookContent = appendOutlookForShape(nonMsoContent, resolvedWidthPx, resolvedWidthPx, {
|
|
182
|
+
shape, imageUrl, backgroundColor, shapeColor,
|
|
183
|
+
borderWidth, borderColor, borderRadius: resolvedBorderRadius,
|
|
184
|
+
heightPx: resolvedHeightPx, text, textColor: color,
|
|
185
|
+
textSize: fontSize, verticalAlign, textAlign, alignment,
|
|
186
|
+
padding, msoBakeImageWithText,
|
|
187
|
+
}, visibilityClass);
|
|
188
|
+
return `
|
|
189
|
+
<table width="100%" style="border-collapse:collapse;table-layout:fixed;max-width:600px;" class="${visibilityClass}">
|
|
190
|
+
<tr>
|
|
191
|
+
<td style="padding:${padding.top || 0}px ${padding.right || 0}px ${padding.bottom || 0}px ${padding.left || 0}px;text-align:${alignment};">
|
|
192
|
+
${outlookContent}
|
|
193
|
+
<!--[if !mso]><!-->
|
|
194
|
+
${nonMsoContent}
|
|
195
|
+
<!--<![endif]-->
|
|
196
|
+
</td>
|
|
197
|
+
</tr>
|
|
198
|
+
</table>`;
|
|
199
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../../src/utils/blocks/text.ts"],"names":[],"mappings":"AAKA,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,GAAG,EAAE,aAAa,CAAC,EAAE,MAAM,UAsItE"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.convertTextBlock = convertTextBlock;
|
|
4
|
+
const buildStyles_1 = require("../buildStyles");
|
|
5
|
+
const gradientUtils_1 = require("../gradientUtils");
|
|
6
|
+
const outlookSupport_1 = require("../outlookSupport");
|
|
7
|
+
const common_1 = require("../common");
|
|
8
|
+
function convertTextBlock(blockData, cellWidthInPx) {
|
|
9
|
+
const { style, props } = blockData.data;
|
|
10
|
+
const visibilityClass = (0, common_1.getVisibilityClass)(props);
|
|
11
|
+
const { width, backgroundColor, padding, borderRadius, borderStyle, borderColor, borderWidth, textContainerBackgroundColor, textContainerPadding, fontSize, backgroundImage, whiteSpace: _whiteSpace, ...rest } = style;
|
|
12
|
+
const bgImageStr = typeof backgroundImage === 'string' ? backgroundImage : '';
|
|
13
|
+
const customCssStr = rest.customCss || '';
|
|
14
|
+
const gradientInCustomCss = !bgImageStr.includes('gradient(') && customCssStr.includes('gradient(')
|
|
15
|
+
? (customCssStr.match(/(?:linear|radial|conic)-gradient\([^)]+(?:\([^)]*\)[^)]*)*\)/)?.[0] || '')
|
|
16
|
+
: '';
|
|
17
|
+
const effectiveGradient = bgImageStr.includes('gradient(') ? bgImageStr : gradientInCustomCss;
|
|
18
|
+
const isGradient = Boolean(effectiveGradient);
|
|
19
|
+
const parsedGradient = isGradient ? (0, gradientUtils_1.parseGradient)(effectiveGradient) : null;
|
|
20
|
+
const rawBgImageUrl = !isGradient && bgImageStr
|
|
21
|
+
? bgImageStr.replace(/^url\(['"]?/, '').replace(/['"]?\)$/, '')
|
|
22
|
+
: null;
|
|
23
|
+
const hasBgImage = Boolean(rawBgImageUrl || isGradient);
|
|
24
|
+
const fallbackBgColor = textContainerBackgroundColor ||
|
|
25
|
+
parsedGradient?.fallback ||
|
|
26
|
+
(0, gradientUtils_1.extractCssFallbackColor)(customCssStr) ||
|
|
27
|
+
'#ffffff';
|
|
28
|
+
const textBoxStyle = { backgroundColor, padding, borderRadius, borderStyle, borderColor, borderWidth };
|
|
29
|
+
const convertedTextStyle = (0, buildStyles_1.buildStyles)(textBoxStyle, { perChanges: [], pxChanges: buildStyles_1.allPxAttributes });
|
|
30
|
+
const innerCustomCss = gradientInCustomCss
|
|
31
|
+
? customCssStr.replace(/background-image\s*:[^;]+;?/gi, '').trim()
|
|
32
|
+
: customCssStr;
|
|
33
|
+
const restForStyles = gradientInCustomCss ? { ...rest, customCss: innerCustomCss } : rest;
|
|
34
|
+
const styles = (0, buildStyles_1.buildStyles)({
|
|
35
|
+
padding: textContainerPadding,
|
|
36
|
+
backgroundColor: hasBgImage ? undefined : textContainerBackgroundColor,
|
|
37
|
+
...restForStyles,
|
|
38
|
+
}, { perChanges: [], pxChanges: buildStyles_1.allPxAttributes });
|
|
39
|
+
const sanitizedText = (props.text ?? "")
|
|
40
|
+
.replace(/<p(\s[^>]*)?>/gi, (_, attrs) => `<div${attrs || ""}>`)
|
|
41
|
+
.replace(/<\/p>/gi, "</div>");
|
|
42
|
+
const navigateToUrl = props.navigateToUrl || "";
|
|
43
|
+
const fontSizeStyle = fontSize != null ? `font-size:${fontSize}px;` : "";
|
|
44
|
+
const blockTextColor = rest.color;
|
|
45
|
+
const processedText = blockTextColor
|
|
46
|
+
? sanitizedText.replace(/<a(\s[^>]*)?>/gi, (match, attrs = '') => {
|
|
47
|
+
if (/style\s*=\s*["'][^"']*\bcolor\s*:/i.test(attrs))
|
|
48
|
+
return match;
|
|
49
|
+
if (/\bstyle\s*=/i.test(attrs)) {
|
|
50
|
+
return `<a${attrs.replace(/(\bstyle\s*=\s*["'])/, `$1color:${blockTextColor};`)}>`;
|
|
51
|
+
}
|
|
52
|
+
return `<a${attrs} style="color:${blockTextColor};">`;
|
|
53
|
+
})
|
|
54
|
+
: sanitizedText;
|
|
55
|
+
const colorStyle = blockTextColor ? `color:${blockTextColor};` : '';
|
|
56
|
+
const convertedTextBox = `<div style="display:block; width:100%; box-sizing:border-box; ${colorStyle}${fontSizeStyle}${convertedTextStyle}">${processedText.replaceAll(/\n/g, "<br>")}</div>`;
|
|
57
|
+
const safeCellWidth = cellWidthInPx ? Math.min(cellWidthInPx, 600) : undefined;
|
|
58
|
+
const textContent = (0, outlookSupport_1.appendOutlookSupport)(convertedTextBox, styles, hasBgImage ? '' : visibilityClass, safeCellWidth);
|
|
59
|
+
const linkColorStyle = blockTextColor ? `color:${blockTextColor};` : 'color:inherit;';
|
|
60
|
+
if (hasBgImage) {
|
|
61
|
+
const msoWidth = cellWidthInPx ? Math.min(cellWidthInPx, 600) : 600;
|
|
62
|
+
const vmlFill = isGradient
|
|
63
|
+
? (() => {
|
|
64
|
+
const vmlAngle = (0, gradientUtils_1.cssAngleToVml)(parsedGradient?.angle || 180);
|
|
65
|
+
const c1 = parsedGradient?.fallback || '#ffffff';
|
|
66
|
+
const c2 = parsedGradient?.colors[parsedGradient.colors.length - 1] || c1;
|
|
67
|
+
return `<v:fill type="gradient" color="${c1}" color2="${c2}" angle="${vmlAngle}" />`;
|
|
68
|
+
})()
|
|
69
|
+
: `<v:fill type="frame" src="${rawBgImageUrl}" color="${fallbackBgColor}" />`;
|
|
70
|
+
const bgCss = isGradient
|
|
71
|
+
? `background:${effectiveGradient};`
|
|
72
|
+
: `background-image:url('${rawBgImageUrl}'); background-position:center center; background-size:cover; background-repeat:no-repeat;`;
|
|
73
|
+
const wrappedContent = `
|
|
74
|
+
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation"
|
|
75
|
+
style="border-collapse:collapse;width:100%;max-width:${msoWidth}px;" class="${visibilityClass}">
|
|
76
|
+
<tr>
|
|
77
|
+
<td width="100%" bgcolor="${fallbackBgColor}" valign="top"
|
|
78
|
+
${!isGradient && rawBgImageUrl ? `background="${rawBgImageUrl}"` : ''}
|
|
79
|
+
style="width:100%;max-width:${msoWidth}px;background-color:${fallbackBgColor};${bgCss}">
|
|
80
|
+
|
|
81
|
+
<!--[if gte mso 9]>
|
|
82
|
+
<v:rect xmlns:v="urn:schemas-microsoft-com:vml"
|
|
83
|
+
fill="true" stroke="false"
|
|
84
|
+
style="width:${msoWidth}px;">
|
|
85
|
+
${vmlFill}
|
|
86
|
+
<v:textbox inset="0,0,0,0">
|
|
87
|
+
<![endif]-->
|
|
88
|
+
|
|
89
|
+
${textContent}
|
|
90
|
+
|
|
91
|
+
<!--[if gte mso 9]>
|
|
92
|
+
</v:textbox>
|
|
93
|
+
</v:rect>
|
|
94
|
+
<![endif]-->
|
|
95
|
+
|
|
96
|
+
</td>
|
|
97
|
+
</tr>
|
|
98
|
+
</table>`;
|
|
99
|
+
return navigateToUrl
|
|
100
|
+
? `<a href="${navigateToUrl}" rel="noreferrer noopener" style="${linkColorStyle}text-decoration:none;cursor:pointer;">${wrappedContent}</a>`
|
|
101
|
+
: wrappedContent;
|
|
102
|
+
}
|
|
103
|
+
return navigateToUrl
|
|
104
|
+
? `<a href="${navigateToUrl}" rel="noreferrer noopener" style="${linkColorStyle}text-decoration:none;cursor:pointer;">${textContent}</a>`
|
|
105
|
+
: textContent;
|
|
106
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"video.d.ts","sourceRoot":"","sources":["../../../src/utils/blocks/video.ts"],"names":[],"mappings":"AAUA,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,GAAG,EAAE,aAAa,EAAE,MAAM,mBA0H5E"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.convertVideoBlock = convertVideoBlock;
|
|
4
|
+
const buildStyles_1 = require("../buildStyles");
|
|
5
|
+
const common_1 = require("../common");
|
|
6
|
+
const FALLBACK_THUMBNAIL = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='480' height='360'%3E%3Crect width='480' height='360' fill='%23cccccc'/%3E%3C/svg%3E`;
|
|
7
|
+
const PLAY_ICON_URL = 'https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png';
|
|
8
|
+
const SPACER_GIF_URL = 'https://app-rsrc.getbee.io/public/resources/multiparser/video_block/video_ratio_16-9.gif';
|
|
9
|
+
async function convertVideoBlock(blockData, cellWidthInPx) {
|
|
10
|
+
const { style = {}, props = {} } = blockData.data;
|
|
11
|
+
const visibilityClass = (0, common_1.getVisibilityClass)(props);
|
|
12
|
+
const { videoUrl, youtubeVideoUrl, thumbnailUrl, altText } = props;
|
|
13
|
+
const hideOnDesktop = Boolean(props.hideOnDesktop);
|
|
14
|
+
const videoLink = youtubeVideoUrl || videoUrl || '#';
|
|
15
|
+
let resolvedThumbnail = thumbnailUrl || FALLBACK_THUMBNAIL;
|
|
16
|
+
if (youtubeVideoUrl) {
|
|
17
|
+
const youtubeId = (0, common_1.extractYouTubeId)(youtubeVideoUrl);
|
|
18
|
+
const vimeoId = (0, common_1.extractVimeoId)(youtubeVideoUrl);
|
|
19
|
+
if (youtubeId) {
|
|
20
|
+
resolvedThumbnail = `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg`;
|
|
21
|
+
}
|
|
22
|
+
else if (vimeoId) {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`https://vimeo.com/api/v2/video/${vimeoId}.json`);
|
|
25
|
+
if (res.ok) {
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
resolvedThumbnail = data?.[0]?.thumbnail_large || resolvedThumbnail;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (_) { }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let percentWidth = '100%';
|
|
34
|
+
if (typeof style?.width === 'string' && style.width.trim().endsWith('%')) {
|
|
35
|
+
percentWidth = style.width.trim();
|
|
36
|
+
}
|
|
37
|
+
else if (typeof style?.width === 'number') {
|
|
38
|
+
percentWidth = `${style.width}%`;
|
|
39
|
+
}
|
|
40
|
+
const paddingLeft = style?.padding?.left || 0;
|
|
41
|
+
const paddingRight = style?.padding?.right || 0;
|
|
42
|
+
const innerContainerWidth = (parseFloat(percentWidth) / 100) * (cellWidthInPx - paddingLeft - paddingRight);
|
|
43
|
+
const thumbnailW = Math.round(innerContainerWidth);
|
|
44
|
+
const thumbnailH = Math.round(thumbnailW / (16 / 9));
|
|
45
|
+
const borderRadius = parseInt(style?.borderRadius || 0);
|
|
46
|
+
const borderWidth = parseInt(style?.borderWidth || 0);
|
|
47
|
+
const borderColor = style?.borderColor || 'transparent';
|
|
48
|
+
const borderStyleProp = style?.borderStyle || 'solid';
|
|
49
|
+
const outerContainerStyles = (0, buildStyles_1.buildStyles)({ ...style, width: undefined, borderColor: undefined, borderRadius: undefined, borderWidth: undefined, borderStyle: undefined }, { perChanges: buildStyles_1.addPxOrPerToAttributes, pxChanges: buildStyles_1.addPxToAttributes });
|
|
50
|
+
const align = style?.textAlign || 'left';
|
|
51
|
+
const playIconW = 65;
|
|
52
|
+
const playIconH = 46;
|
|
53
|
+
const ovalSize = 65;
|
|
54
|
+
const ovalLeft = Math.round(thumbnailW / 2 - ovalSize / 2);
|
|
55
|
+
const ovalTop = Math.round(thumbnailH / 2 - ovalSize / 2);
|
|
56
|
+
const triW = 23;
|
|
57
|
+
const triH = 33;
|
|
58
|
+
const triLeft = Math.round(thumbnailW / 2 - triW / 2 + 3);
|
|
59
|
+
const triTop = Math.round(thumbnailH / 2 - triH / 2);
|
|
60
|
+
const borderCss = borderWidth > 0 ? `border:${borderWidth}px ${borderStyleProp} ${borderColor};` : '';
|
|
61
|
+
const radiusCss = borderRadius > 0 ? `border-radius:${borderRadius}px; overflow:hidden;` : '';
|
|
62
|
+
const minHeight = Math.round(thumbnailW * 0.3);
|
|
63
|
+
const outlookContent = hideOnDesktop ? '' : `<!--[if vml]>
|
|
64
|
+
<v:group xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
|
|
65
|
+
coordsize="${thumbnailW},${thumbnailH}" coordorigin="0,0"
|
|
66
|
+
href="${videoLink}"
|
|
67
|
+
style="width:${thumbnailW}px;height:${thumbnailH}px;">
|
|
68
|
+
<v:rect fill="t" stroked="f" style="position:absolute;width:${thumbnailW};height:${thumbnailH};">
|
|
69
|
+
<v:fill src="${resolvedThumbnail}" type="frame"/>
|
|
70
|
+
</v:rect>
|
|
71
|
+
<v:oval fill="t" strokecolor="#ffffff" strokeweight="3px"
|
|
72
|
+
style="position:absolute;left:${ovalLeft};top:${ovalTop};width:${ovalSize};height:${ovalSize}">
|
|
73
|
+
<v:fill color="#ffffff" opacity="100%" />
|
|
74
|
+
</v:oval>
|
|
75
|
+
<v:shape coordsize="24,32" path="m,l,32,24,16,xe" fillcolor="#000000" stroked="f"
|
|
76
|
+
style="position:absolute;left:${triLeft};top:${triTop};width:${triW};height:${triH};" />
|
|
77
|
+
</v:group>
|
|
78
|
+
<![endif]-->`;
|
|
79
|
+
const nonMsoContent = `<!--[if !vml]><!-->
|
|
80
|
+
<a href="${videoLink}" target="_blank"
|
|
81
|
+
style="display:block; text-decoration:none; background-image:url('${resolvedThumbnail}'); background-size:cover; background-position:center; ${borderCss}${radiusCss}">
|
|
82
|
+
<table cellpadding="0" cellspacing="0" border="0" width="100%"
|
|
83
|
+
background="${resolvedThumbnail}"
|
|
84
|
+
role="presentation"
|
|
85
|
+
style="background-image:url('${resolvedThumbnail}'); background-size:cover; background-position:center center; background-repeat:no-repeat; min-height:${minHeight}px;">
|
|
86
|
+
<tr>
|
|
87
|
+
<td width="25%" style="line-height:0; font-size:0; padding:0;">
|
|
88
|
+
<img src="${SPACER_GIF_URL}" width="100%" border="0" alt=""
|
|
89
|
+
style="display:block; height:auto; opacity:0; visibility:hidden;" />
|
|
90
|
+
</td>
|
|
91
|
+
<td width="50%" align="center" valign="middle"
|
|
92
|
+
style="text-align:center; vertical-align:middle; padding:0;">
|
|
93
|
+
<img src="${PLAY_ICON_URL}" width="${playIconW}" height="${playIconH}" alt="Play"
|
|
94
|
+
style="display:block; width:${playIconW}px; height:${playIconH}px; border:0; margin:0 auto;" />
|
|
95
|
+
</td>
|
|
96
|
+
<td width="25%" style="padding:0;"> </td>
|
|
97
|
+
</tr>
|
|
98
|
+
</table>
|
|
99
|
+
</a>
|
|
100
|
+
<!--<![endif]-->`;
|
|
101
|
+
return `
|
|
102
|
+
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
103
|
+
style="border-collapse:collapse; max-width:600px; margin:0; padding:0;"
|
|
104
|
+
class="${visibilityClass}">
|
|
105
|
+
<tr>
|
|
106
|
+
<td align="${align}" style="${outerContainerStyles}">
|
|
107
|
+
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
|
108
|
+
align="${align}"
|
|
109
|
+
style="border-collapse:collapse; max-width:${thumbnailW}px; width:${percentWidth};">
|
|
110
|
+
<tr>
|
|
111
|
+
<td align="${align}" style="padding:0;">
|
|
112
|
+
${outlookContent}${nonMsoContent}
|
|
113
|
+
</td>
|
|
114
|
+
</tr>
|
|
115
|
+
</table>
|
|
116
|
+
</td>
|
|
117
|
+
</tr>
|
|
118
|
+
</table>`;
|
|
119
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const addPxToAttributes: string[];
|
|
2
|
+
export declare const addPxOrPerToAttributes: string[];
|
|
3
|
+
export declare const allPxAttributes: string[];
|
|
4
|
+
export declare const tableCommonStyle = "border-collapse:collapse; table-layout:fixed";
|
|
5
|
+
export declare function sanitizeFontFamily(fontFamily: string): string;
|
|
6
|
+
export declare function buildStyles(style: any, { pxChanges, perChanges }: {
|
|
7
|
+
pxChanges: string[];
|
|
8
|
+
perChanges: string[];
|
|
9
|
+
}): string;
|
|
10
|
+
//# sourceMappingURL=buildStyles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"buildStyles.d.ts","sourceRoot":"","sources":["../../src/utils/buildStyles.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB,UAA8C,CAAC;AAC7E,eAAO,MAAM,sBAAsB,UAAsB,CAAC;AAC1D,eAAO,MAAM,eAAe,UAAoD,CAAC;AAEjF,eAAO,MAAM,gBAAgB,iDAAiD,CAAC;AAQ/E,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAe7D;AAED,wBAAgB,WAAW,CACzB,KAAK,EAAE,GAAG,EACV,EAAE,SAAS,EAAE,UAAU,EAAE,EAAE;IAAE,SAAS,EAAE,MAAM,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,UAmEzE"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tableCommonStyle = exports.allPxAttributes = exports.addPxOrPerToAttributes = exports.addPxToAttributes = void 0;
|
|
4
|
+
exports.sanitizeFontFamily = sanitizeFontFamily;
|
|
5
|
+
exports.buildStyles = buildStyles;
|
|
6
|
+
const fontFallback_1 = require("./fontFallback");
|
|
7
|
+
exports.addPxToAttributes = ["fontSize", "borderRadius", "borderWidth"];
|
|
8
|
+
exports.addPxOrPerToAttributes = ["width", "height"];
|
|
9
|
+
exports.allPxAttributes = [...exports.addPxToAttributes, ...exports.addPxOrPerToAttributes];
|
|
10
|
+
exports.tableCommonStyle = "border-collapse:collapse; table-layout:fixed";
|
|
11
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
12
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
|
13
|
+
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace',
|
|
14
|
+
'ui-rounded', 'emoji', 'math', 'fangsong',
|
|
15
|
+
]);
|
|
16
|
+
function sanitizeFontFamily(fontFamily) {
|
|
17
|
+
if (!fontFamily)
|
|
18
|
+
return fontFamily;
|
|
19
|
+
return fontFamily
|
|
20
|
+
.split(',')
|
|
21
|
+
.map(font => {
|
|
22
|
+
const trimmed = font.trim();
|
|
23
|
+
const unquoted = trimmed.replace(/^["']|["']$/g, '').trim();
|
|
24
|
+
if (!unquoted)
|
|
25
|
+
return '';
|
|
26
|
+
if (GENERIC_FONT_FAMILIES.has(unquoted.toLowerCase()) || !/\s/.test(unquoted)) {
|
|
27
|
+
return unquoted;
|
|
28
|
+
}
|
|
29
|
+
return `'${unquoted.replace(/'/g, "\\'")}'`;
|
|
30
|
+
})
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(', ');
|
|
33
|
+
}
|
|
34
|
+
function buildStyles(style, { pxChanges, perChanges }) {
|
|
35
|
+
if (!style)
|
|
36
|
+
style = {};
|
|
37
|
+
const stylesObj = {};
|
|
38
|
+
const INVALID_KEYS = [
|
|
39
|
+
"columns", "cellWidths", "cellWidth", "childWidth",
|
|
40
|
+
"visibility", "hideOnMobile", "hideOnDesktop", "label", "alignment",
|
|
41
|
+
];
|
|
42
|
+
Object.entries(style).forEach(([key, value]) => {
|
|
43
|
+
if (key === "customCss")
|
|
44
|
+
return;
|
|
45
|
+
if (INVALID_KEYS.includes(key))
|
|
46
|
+
return;
|
|
47
|
+
if (value === undefined || value === null || value === "")
|
|
48
|
+
return;
|
|
49
|
+
if ((key === "padding" || key === "buttonPadding") && typeof value === "object") {
|
|
50
|
+
const pad = value;
|
|
51
|
+
const safePad = {
|
|
52
|
+
top: Number.isFinite(pad.top) ? pad.top : 0,
|
|
53
|
+
right: Number.isFinite(pad.right) ? pad.right : 0,
|
|
54
|
+
bottom: Number.isFinite(pad.bottom) ? pad.bottom : 0,
|
|
55
|
+
left: Number.isFinite(pad.left) ? pad.left : 0,
|
|
56
|
+
};
|
|
57
|
+
value = `${safePad.top}px ${safePad.right}px ${safePad.bottom}px ${safePad.left}px`;
|
|
58
|
+
}
|
|
59
|
+
if (key === "fontFamily" && typeof value === "string") {
|
|
60
|
+
value = sanitizeFontFamily((0, fontFallback_1.withFontFallback)(value));
|
|
61
|
+
}
|
|
62
|
+
if (key === "backgroundImage" && typeof value === "string"
|
|
63
|
+
&& !String(value).startsWith("url(")
|
|
64
|
+
&& !String(value).toLowerCase().includes("gradient(")) {
|
|
65
|
+
value = `url('${value}')`;
|
|
66
|
+
}
|
|
67
|
+
if (key === "lineHeight" && typeof value === "number") {
|
|
68
|
+
stylesObj["line-height"] = value >= 4 ? `${value}px` : String(value);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
72
|
+
if (pxChanges.includes(key)) {
|
|
73
|
+
if (typeof value === "number") {
|
|
74
|
+
stylesObj[cssKey] = `${Math.round(value * 100) / 100}px`;
|
|
75
|
+
}
|
|
76
|
+
else if (typeof value === "string" && value.includes("null")) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
stylesObj[cssKey] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (perChanges.includes(key)) {
|
|
84
|
+
if (typeof value === "number") {
|
|
85
|
+
stylesObj[cssKey] = `${value}%`;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
stylesObj[cssKey] = value;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
stylesObj[cssKey] = value;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const parts = Object.entries(stylesObj)
|
|
96
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
97
|
+
.map(([k, v]) => `${k}:${v}`);
|
|
98
|
+
if (style.customCss)
|
|
99
|
+
parts.push(style.customCss);
|
|
100
|
+
return parts.join('; ').replace(/;\s*$/, '').trim();
|
|
101
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function extractCssFallbackColor(customCss?: string): string;
|
|
2
|
+
export declare function parseGradient(gradient?: string): {
|
|
3
|
+
angle: number;
|
|
4
|
+
colors: string[];
|
|
5
|
+
fallback: string;
|
|
6
|
+
} | null;
|
|
7
|
+
export declare function cssAngleToVml(angle: number): number;
|
|
8
|
+
//# sourceMappingURL=gradientUtils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gradientUtils.d.ts","sourceRoot":"","sources":["../../src/utils/gradientUtils.ts"],"names":[],"mappings":"AAsBA,wBAAgB,uBAAuB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAKlE;AAED,wBAAgB,aAAa,CAAC,QAAQ,CAAC,EAAE,MAAM;;;;SAyB9C;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,UAE1C"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractCssFallbackColor = extractCssFallbackColor;
|
|
4
|
+
exports.parseGradient = parseGradient;
|
|
5
|
+
exports.cssAngleToVml = cssAngleToVml;
|
|
6
|
+
const GRADIENT_KEYWORDS = new Set([
|
|
7
|
+
'linear', 'radial', 'conic', 'gradient',
|
|
8
|
+
'to', 'at', 'top', 'bottom', 'left', 'right', 'center',
|
|
9
|
+
'closest', 'farthest', 'corner', 'side', 'circle', 'ellipse',
|
|
10
|
+
'deg', 'turn', 'rad', 'grad', 'from', 'in',
|
|
11
|
+
'url',
|
|
12
|
+
]);
|
|
13
|
+
function firstGradientColor(gradient) {
|
|
14
|
+
const tokenRe = /#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|([a-zA-Z-]+)/g;
|
|
15
|
+
let m;
|
|
16
|
+
while ((m = tokenRe.exec(gradient)) !== null) {
|
|
17
|
+
const namedWord = m[1];
|
|
18
|
+
if (namedWord) {
|
|
19
|
+
if (!GRADIENT_KEYWORDS.has(namedWord.toLowerCase()))
|
|
20
|
+
return namedWord;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
return m[0];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
function extractCssFallbackColor(customCss) {
|
|
29
|
+
if (!customCss)
|
|
30
|
+
return '';
|
|
31
|
+
const gradientMatch = customCss.match(/(?:linear|radial|conic)-gradient\(([^)]+(?:\([^)]*\)[^)]*)*)\)/);
|
|
32
|
+
if (!gradientMatch)
|
|
33
|
+
return '';
|
|
34
|
+
return firstGradientColor(gradientMatch[1]);
|
|
35
|
+
}
|
|
36
|
+
function parseGradient(gradient) {
|
|
37
|
+
if (!gradient)
|
|
38
|
+
return null;
|
|
39
|
+
const lower = gradient.toLowerCase();
|
|
40
|
+
const degMatch = gradient.match(/(\d+(?:\.\d+)?)deg/);
|
|
41
|
+
let angle = 180;
|
|
42
|
+
if (degMatch) {
|
|
43
|
+
angle = parseFloat(degMatch[1]);
|
|
44
|
+
}
|
|
45
|
+
else if (lower.includes('to right'))
|
|
46
|
+
angle = 90;
|
|
47
|
+
else if (lower.includes('to left'))
|
|
48
|
+
angle = 270;
|
|
49
|
+
else if (lower.includes('to top'))
|
|
50
|
+
angle = 0;
|
|
51
|
+
const colors = [];
|
|
52
|
+
const tokenRe = /#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|([a-zA-Z-]+)/g;
|
|
53
|
+
let m;
|
|
54
|
+
while ((m = tokenRe.exec(gradient)) !== null) {
|
|
55
|
+
const namedWord = m[1];
|
|
56
|
+
if (namedWord) {
|
|
57
|
+
if (!GRADIENT_KEYWORDS.has(namedWord.toLowerCase()))
|
|
58
|
+
colors.push(namedWord);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
colors.push(m[0]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { angle, colors, fallback: colors[0] || '#ffffff' };
|
|
65
|
+
}
|
|
66
|
+
function cssAngleToVml(angle) {
|
|
67
|
+
return (angle + 90) % 360;
|
|
68
|
+
}
|