Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/entities/block/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface BaseAtom {
type: AtomType
spacing?: SpacingValue
hiddenOnMobile?: boolean
opacity?: number
}

export interface TextAtom extends BaseAtom {
Expand Down Expand Up @@ -61,6 +62,7 @@ export interface BlockSettings {
backgroundImage?: BackgroundImageValue
borderRadius?: BorderRadiusValue
border?: BorderValue
opacity?: number
}

export interface RowSettings {
Expand All @@ -74,6 +76,7 @@ export interface RowSettings {
height?: number
gap: number
widthMode: 'fill' | 'hug'
opacity?: number
}

export interface CellSettings {
Expand All @@ -88,6 +91,7 @@ export interface CellSettings {
borderRadius?: BorderRadiusValue
width?: number // процент (0-100)
height?: number
opacity?: number
}

export interface RowNode {
Expand Down
1 change: 1 addition & 0 deletions src/entities/style/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './border'
export * from './border-radius'
export * from './email-color'
export * from './opacity'
export * from './types'
24 changes: 24 additions & 0 deletions src/entities/style/opacity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const DEFAULT_OPACITY = 100

export function normalizeOpacity(value: unknown): number | undefined {
return typeof value === 'number'
&& Number.isInteger(value)
&& value >= 0
&& value <= DEFAULT_OPACITY
? value
: undefined
}

export function resolveOpacity(value?: number): number {
return normalizeOpacity(value) ?? DEFAULT_OPACITY
}

export function resolveOpacityStyle(value?: number): number {
// Classic Outlook may ignore CSS opacity and render the content opaque. We intentionally
// emit no filter/VML fallback: opaque degradation keeps content readable and predictable.
return resolveOpacity(value) / DEFAULT_OPACITY
}

export function multiplyOpacityStyles(...values: number[]): number {
return Number(values.reduce((product, value) => product * value, 1).toFixed(6))
}
91 changes: 82 additions & 9 deletions src/entities/template/template-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
normalizeBorderRadiusValue,
normalizeBorderValue,
normalizeEmailColor,
normalizeOpacity,
} from '@/entities/style'
import { clone } from '@/shared/lib/clone'
import { splitCssDeclarations, splitCssProperty } from '@/shared/lib/css-style'
Expand Down Expand Up @@ -602,6 +603,10 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
pushIssue(issues, `${path}.hiddenOnMobile`, 'atom.hiddenOnMobile must be a boolean')
}

if (value.opacity !== undefined && normalizeOpacity(value.opacity) === undefined) {
pushIssue(issues, `${path}.opacity`, 'atom.opacity must be an integer between 0 and 100')
}

if (value.type === 'text') {
validateAllowedProperties(
value,
Expand All @@ -616,6 +621,7 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
'border',
'spacing',
'hiddenOnMobile',
'opacity',
],
issues,
)
Expand Down Expand Up @@ -669,6 +675,7 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
'padding',
'spacing',
'hiddenOnMobile',
'opacity',
],
issues,
)
Expand Down Expand Up @@ -704,7 +711,7 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
validateAllowedProperties(
value,
path,
['id', 'type', 'color', 'height', 'spacing', 'hiddenOnMobile'],
['id', 'type', 'color', 'height', 'spacing', 'hiddenOnMobile', 'opacity'],
issues,
)

Expand Down Expand Up @@ -736,6 +743,7 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
'border',
'spacing',
'hiddenOnMobile',
'opacity',
],
issues,
)
Expand Down Expand Up @@ -770,7 +778,12 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
return
}

validateAllowedProperties(value, path, ['id', 'type', 'spacing', 'hiddenOnMobile'], issues)
validateAllowedProperties(
value,
path,
['id', 'type', 'spacing', 'hiddenOnMobile', 'opacity'],
issues,
)

pushIssue(issues, `${path}.type`, 'Unsupported atom type')
}
Expand Down Expand Up @@ -811,6 +824,7 @@ function validateCellNode(value: unknown, path: string, issues: TemplateValidati
'width',
'height',
'collapseOnMobile',
'opacity',
],
issues,
)
Expand Down Expand Up @@ -892,6 +906,16 @@ function validateCellNode(value: unknown, path: string, issues: TemplateValidati
)
}
validateBorderValue(value.settings.border, `${path}.settings.border`, issues)
if (
value.settings.opacity !== undefined
&& normalizeOpacity(value.settings.opacity) === undefined
) {
pushIssue(
issues,
`${path}.settings.opacity`,
'cell.settings.opacity must be an integer between 0 and 100',
)
}
}

if (!Array.isArray(value.children)) {
Expand Down Expand Up @@ -939,6 +963,7 @@ function validateRowNode(value: unknown, path: string, issues: TemplateValidatio
'height',
'gap',
'widthMode',
'opacity',
],
issues,
)
Expand Down Expand Up @@ -1007,6 +1032,16 @@ function validateRowNode(value: unknown, path: string, issues: TemplateValidatio
)
}
validateBorderValue(value.settings.border, `${path}.settings.border`, issues)
if (
value.settings.opacity !== undefined
&& normalizeOpacity(value.settings.opacity) === undefined
) {
pushIssue(
issues,
`${path}.settings.opacity`,
'row.settings.opacity must be an integer between 0 and 100',
)
}
}

if (!Array.isArray(value.cells)) {
Expand Down Expand Up @@ -1047,7 +1082,7 @@ function validateCanvasBlockInstance(
validateAllowedProperties(
block.settings,
`${path}.block.settings`,
['spacing', 'backgroundColor', 'backgroundImage', 'borderRadius', 'border'],
['spacing', 'backgroundColor', 'backgroundImage', 'borderRadius', 'border', 'opacity'],
issues,
)

Expand All @@ -1074,6 +1109,16 @@ function validateCanvasBlockInstance(
)
}
validateBorderValue(block.settings.border, `${path}.block.settings.border`, issues)
if (
block.settings.opacity !== undefined
&& normalizeOpacity(block.settings.opacity) === undefined
) {
pushIssue(
issues,
`${path}.block.settings.opacity`,
'block.settings.opacity must be an integer between 0 and 100',
)
}
}

if (!Array.isArray(block.rows)) {
Expand Down Expand Up @@ -1227,16 +1272,18 @@ function sanitizeAtoms(atoms: Atom[]): Atom[] {
: {}),
spacing: atom.spacing,
hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile),
...sanitizeOpacityProperty(atom.opacity),
...sanitizeBorderRadiusProperty(atom.borderRadius),
...sanitizeBorderProperty(atom.border),
}
}

if (atom.type === 'image') {
const { border: _border, borderRadius: _borderRadius, ...image } = atom
const { border: _border, borderRadius: _borderRadius, opacity: _opacity, ...image } = atom
return {
...image,
hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile),
...sanitizeOpacityProperty(atom.opacity),
src: typeof atom.src === 'string' ? atom.src : '',
link: typeof atom.link === 'string' ? atom.link : '',
alt: typeof atom.alt === 'string' ? atom.alt : '',
Expand All @@ -1248,29 +1295,37 @@ function sanitizeAtoms(atoms: Atom[]): Atom[] {
}

if (atom.type === 'button') {
const { border: _border, ...button } = atom
const { border: _border, opacity: _opacity, ...button } = atom
return {
...button,
backgroundColor: normalizeEmailColor(atom.backgroundColor)!,
color: normalizeEmailColor(atom.color)!,
borderRadius: normalizeBorderRadiusValue(atom.borderRadius)!,
hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile),
...sanitizeOpacityProperty(atom.opacity),
...sanitizeBorderProperty(atom.border),
}
}

if (atom.type === 'divider') {
const { opacity: _opacity, ...divider } = atom
return {
...atom,
...divider,
color: normalizeEmailColor(atom.color)!,
hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile),
...sanitizeOpacityProperty(atom.opacity),
}
}

return atom
})
}

function sanitizeOpacityProperty(opacity: unknown): { opacity?: number } {
const normalized = normalizeOpacity(opacity)
return normalized === undefined || normalized === 100 ? {} : { opacity: normalized }
}

function sanitizeBorderProperty(border: unknown): {
border?: ReturnType<typeof normalizeBorderValue>
} {
Expand All @@ -1287,7 +1342,12 @@ function sanitizeBorderRadiusProperty(borderRadius: unknown): {

function sanitizeCellNodes(cells: CellNode[]): CellNode[] {
return cells.map((cell) => {
const { border: _border, borderRadius: _borderRadius, ...cellSettings } = cell.settings
const {
border: _border,
borderRadius: _borderRadius,
opacity: _opacity,
...cellSettings
} = cell.settings
const settings = {
...cellSettings,
backgroundColor: normalizeEmailColor(cell.settings.backgroundColor, {
Expand All @@ -1297,6 +1357,7 @@ function sanitizeCellNodes(cells: CellNode[]): CellNode[] {
hiddenOnMobile: toOptionalBoolean(cell.settings?.hiddenOnMobile),
...sanitizeBorderRadiusProperty(cell.settings?.borderRadius),
...sanitizeBorderProperty(cell.settings?.border),
...sanitizeOpacityProperty(cell.settings?.opacity),
}

// Hard-drop legacy per-cell collapse to keep exported schema clean.
Expand All @@ -1314,7 +1375,12 @@ function sanitizeCellNodes(cells: CellNode[]): CellNode[] {

function sanitizeRowNodes(rows: RowNode[]): RowNode[] {
return rows.map((row) => {
const { border: _border, borderRadius: _borderRadius, ...rowSettings } = row.settings
const {
border: _border,
borderRadius: _borderRadius,
opacity: _opacity,
...rowSettings
} = row.settings
return {
id: row.id,
type: 'row',
Expand All @@ -1331,14 +1397,20 @@ function sanitizeRowNodes(rows: RowNode[]): RowNode[] {
widthMode: row.settings?.widthMode === 'hug' ? 'hug' : 'fill',
...sanitizeBorderRadiusProperty(row.settings?.borderRadius),
...sanitizeBorderProperty(row.settings?.border),
...sanitizeOpacityProperty(row.settings?.opacity),
},
cells: sanitizeCellNodes(row.cells),
}
})
}

function sanitizeBlock(block: BlockNode): BlockNode {
const { border: _border, borderRadius: _borderRadius, ...blockSettings } = block.settings
const {
border: _border,
borderRadius: _borderRadius,
opacity: _opacity,
...blockSettings
} = block.settings
return {
...block,
settings: {
Expand All @@ -1348,6 +1420,7 @@ function sanitizeBlock(block: BlockNode): BlockNode {
})!,
...sanitizeBorderRadiusProperty(block.settings?.borderRadius),
...sanitizeBorderProperty(block.settings?.border),
...sanitizeOpacityProperty(block.settings?.opacity),
},
rows: sanitizeRowNodes(block.rows),
}
Expand Down
64 changes: 64 additions & 0 deletions src/features/editor/components/tools/OpacitySettings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { normalizeOpacity } from '@/entities/style'
import { Input } from '@/shared/ui/input'

const props = defineProps<{ id: string, title: string, value: number }>()
const emit = defineEmits<{ (event: 'update:value', value: number): void }>()

const draft = ref<string | number>(props.value)

watch(
() => props.value,
(value) => {
draft.value = value
},
)

function update(value: string | number) {
draft.value = value
const opacity = parseDraft(value)
if (opacity !== undefined)
emit('update:value', opacity)
}

function parseDraft(value: string | number) {
if (typeof value === 'string' && value.trim() === '')
return undefined
return normalizeOpacity(Number(value))
}

function restoreInvalidDraft() {
if (parseDraft(draft.value) === undefined)
draft.value = props.value
}
</script>

<template>
<div
data-slot="opacity-settings"
class="space-y-2"
>
<EditorToolLabel>{{ title }}</EditorToolLabel>
<div class="relative">
<Input
:id="id"
v-model="draft"
type="number"
min="0"
max="100"
step="1"
class="pr-8"
:aria-invalid="parseDraft(draft) === undefined"
aria-label="Opacity"
@update:model-value="update"
@blur="restoreInvalidDraft"
/>
<span
class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-xs text-muted-foreground"
>
%
</span>
</div>
</div>
</template>
Loading