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: 1 addition & 3 deletions src/entities/block/block-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,9 @@ export function createButtonAtom(): ButtonAtom {
return {
id: nanoid(8),
type: 'button',
text: 'Button',
value: '<span style="color:#FFFFFF;font-size:16px">Button</span>',
link: 'https://example.com',
backgroundColor: '#4F46E5',
color: '#FFFFFF',
fontSize: 16,
borderRadius: createBorderRadiusValue(4),
padding,
spacing: createAtomSpacing(padding),
Expand Down
4 changes: 1 addition & 3 deletions src/entities/block/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,9 @@ export interface TextAtom extends BaseAtom {

export interface ButtonAtom extends BaseAtom {
type: 'button'
text: string
value: string
link: string
backgroundColor: string
color: string
fontSize: number
borderRadius: BorderRadiusValue
padding: [number, number, number, number]
border?: BorderValue
Expand Down
42 changes: 31 additions & 11 deletions src/entities/template/template-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,29 @@ const EMAIL_TEXT_SANITIZE_OPTIONS: IOptions = {
parseStyleAttributes: true,
}

const EMAIL_BUTTON_ALLOWED_TAGS = ['b', 'code', 'em', 'i', 'span', 'strong', 'sub', 'sup', 'u']

const EMAIL_BUTTON_ALLOWED_STYLES: NonNullable<IOptions['allowedStyles']> = {
'*': {
'color': EMAIL_TEXT_ALLOWED_STYLES['*']!.color!,
'font-family': EMAIL_TEXT_ALLOWED_STYLES['*']!['font-family']!,
'font-size': EMAIL_TEXT_ALLOWED_STYLES['*']!['font-size']!,
'font-style': EMAIL_TEXT_ALLOWED_STYLES['*']!['font-style']!,
'font-weight': EMAIL_TEXT_ALLOWED_STYLES['*']!['font-weight']!,
'letter-spacing': EMAIL_TEXT_ALLOWED_STYLES['*']!['letter-spacing']!,
'line-height': EMAIL_TEXT_ALLOWED_STYLES['*']!['line-height']!,
'text-decoration': EMAIL_TEXT_ALLOWED_STYLES['*']!['text-decoration']!,
},
}

const EMAIL_BUTTON_SANITIZE_OPTIONS: IOptions = {
allowedAttributes: { '*': ['style'] },
allowedStyles: EMAIL_BUTTON_ALLOWED_STYLES,
allowedTags: EMAIL_BUTTON_ALLOWED_TAGS,
disallowedTagsMode: 'discard',
parseStyleAttributes: true,
}

function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
Expand Down Expand Up @@ -665,11 +688,9 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
[
'id',
'type',
'text',
'value',
'link',
'backgroundColor',
'color',
'fontSize',
'borderRadius',
'border',
'padding',
Expand All @@ -680,8 +701,8 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
issues,
)

if (!isString(value.text))
pushIssue(issues, `${path}.text`, 'button atom text must be a string')
if (!isString(value.value))
pushIssue(issues, `${path}.value`, 'button atom value must be a string')
if (!isString(value.link))
pushIssue(issues, `${path}.link`, 'button atom link must be a string')
if (!normalizeEmailColor(value.backgroundColor)) {
Expand All @@ -691,11 +712,6 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs
'button atom backgroundColor must be an email color',
)
}
if (!normalizeEmailColor(value.color))
pushIssue(issues, `${path}.color`, 'button atom color must be an email color')
if (!isFiniteNumber(value.fontSize)) {
pushIssue(issues, `${path}.fontSize`, 'button atom fontSize must be a finite number')
}
validateBorderRadiusValue(value.borderRadius, `${path}.borderRadius`, issues)
validateSpacingTuple(value.padding, path, issues, 'padding')

Expand Down Expand Up @@ -1234,6 +1250,10 @@ export function sanitizeTextEditorHtml(value: string) {
return sanitizeHtmlLib(normalizeTextStyleColors(value), EMAIL_TEXT_SANITIZE_OPTIONS)
}

export function sanitizeButtonEditorHtml(value: string) {
return sanitizeHtmlLib(normalizeTextStyleColors(value), EMAIL_BUTTON_SANITIZE_OPTIONS)
}

function _sanitizeTools(tools: Tool[]): Tool[] {
return tools.map((tool) => {
if (tool.type === 'textEditor') {
Expand Down Expand Up @@ -1298,8 +1318,8 @@ function sanitizeAtoms(atoms: Atom[]): Atom[] {
const { border: _border, opacity: _opacity, ...button } = atom
return {
...button,
value: sanitizeButtonEditorHtml(atom.value),
backgroundColor: normalizeEmailColor(atom.backgroundColor)!,
color: normalizeEmailColor(atom.color)!,
borderRadius: normalizeBorderRadiusValue(atom.borderRadius)!,
hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile),
...sanitizeOpacityProperty(atom.opacity),
Expand Down
75 changes: 53 additions & 22 deletions src/features/editor/components/tools/text/InlineTextEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { AtomRef } from '@/features/editor/model'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { sanitizeTextEditorHtml } from '@/entities/template'
import { sanitizeButtonEditorHtml, sanitizeTextEditorHtml } from '@/entities/template'
import { useCanvas, useHistory } from '@/features/editor/model'
import { useInlineTextEditing } from './composables/use-inline-text-editing'
import {
Expand All @@ -11,10 +11,14 @@ import {
textSelectionPositionAtCoords,
textSelectionPositionAtOffset,
} from './inline-text-session'
import { createInlineTextExtensions, normalizeInlineEditorHtml } from './text-editor-core'
import {
createInlineButtonExtensions,
createInlineTextExtensions,
normalizeInlineEditorHtml,
} from './text-editor-core'

interface Props {
atomRef: AtomRef<'text'>
atomRef: AtomRef<'text'> | AtomRef<'button'>
value: string
}

Expand All @@ -32,23 +36,30 @@ const {
} = useInlineTextEditing()
let initialSelectionRafId: number | undefined
let finished = false
let lastModelValue = sanitizeTextEditorHtml(props.value)

function getTextAtomValue() {
const state = getNodePropertyState(props.atomRef, 'value')
const profile = props.atomRef.atomType
const sanitizeEditorHtml = profile === 'button' ? sanitizeButtonEditorHtml : sanitizeTextEditorHtml
let lastModelValue = sanitizeEditorHtml(props.value)

function getAtomValue() {
const state
= props.atomRef.atomType === 'button'
? getNodePropertyState(props.atomRef, 'value')
: getNodePropertyState(props.atomRef, 'value')
return state.kind === 'value' ? state.value : undefined
}

function updateTextAtomValue(html: string) {
return updateNodeProperty({ ref: props.atomRef, property: 'value', value: html })
function updateAtomValue(html: string) {
return props.atomRef.atomType === 'button'
? updateNodeProperty({ ref: props.atomRef, property: 'value', value: html })
: updateNodeProperty({ ref: props.atomRef, property: 'value', value: html })
}

function persistEditorValue() {
if (!editor.value)
return

const value = sanitizeTextEditorHtml(editor.value.getHTML())
updateTextAtomValue(value)
const value = sanitizeEditorHtml(editor.value.getHTML())
updateAtomValue(value)
lastModelValue = value
}

Expand All @@ -63,8 +74,13 @@ function finishEditing() {
}

function onRootKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || event.defaultPrevented)
if (
event.defaultPrevented
|| event.isComposing
|| (event.key !== 'Escape' && (profile !== 'button' || event.key !== 'Enter'))
) {
return
}

event.preventDefault()
finishEditing()
Expand Down Expand Up @@ -93,16 +109,29 @@ function focusInitialSelection() {

onMounted(() => {
editor.value = new Editor({
content: normalizeInlineEditorHtml(sanitizeTextEditorHtml(props.value)),
extensions: createInlineTextExtensions(),
content:
profile === 'button'
? sanitizeEditorHtml(props.value)
: normalizeInlineEditorHtml(sanitizeEditorHtml(props.value)),
extensions:
profile === 'button' ? createInlineButtonExtensions() : createInlineTextExtensions(),
editorProps: {
attributes: {
'class': 'p-inline-text-editor__content',
'aria-label': 'Inline text editor',
'class': [
'p-inline-text-editor__content',
profile === 'button' ? 'p-inline-text-editor__content--button' : '',
]
.filter(Boolean)
.join(' '),
'aria-label': profile === 'button' ? 'Inline button text editor' : 'Inline text editor',
},
handleKeyDown: (_view, event) => {
if (event.key !== 'Escape')
if (
event.isComposing
|| (event.key !== 'Escape' && (profile !== 'button' || event.key !== 'Enter'))
) {
return false
}

event.preventDefault()
finishEditing()
Expand All @@ -111,14 +140,14 @@ onMounted(() => {
},
onUpdate: ({ editor: currentEditor }) => {
requestToolbarUpdate()
updateTextAtomValue(currentEditor.getHTML())
updateAtomValue(sanitizeEditorHtml(currentEditor.getHTML()))
},
onSelectionUpdate: () => {
requestToolbarUpdate()
},
})

registerEditor(props.atomRef.atomId, editor.value)
registerEditor(props.atomRef.atomId, editor.value, profile)

nextTick(() => {
initialSelectionRafId = window.requestAnimationFrame(() => {
Expand All @@ -131,8 +160,8 @@ onMounted(() => {
watch(
() => props.value,
(value) => {
const nextValue = sanitizeTextEditorHtml(value)
const currentValue = editor.value ? sanitizeTextEditorHtml(editor.value.getHTML()) : ''
const nextValue = sanitizeEditorHtml(value)
const currentValue = editor.value ? sanitizeEditorHtml(editor.value.getHTML()) : ''
lastModelValue = nextValue

if (editor.value && currentValue !== nextValue)
Expand All @@ -146,7 +175,7 @@ onBeforeUnmount(() => {

if (
canPersistInlineTextOnUnmount({
currentModelValue: getTextAtomValue(),
currentModelValue: getAtomValue(),
finished,
lastModelValue,
})
Expand All @@ -166,6 +195,8 @@ onBeforeUnmount(() => {
ref="root"
data-slot="inline-text-editor"
data-inline-text-editor
data-inline-atom-editor
:data-inline-editor-profile="profile"
data-selection-content
class="p-inline-text-editor"
@click.stop
Expand Down
Loading