feat(front-end): rich text
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 25s
Build and Deploy / deploy (push) Successful in 9s

This commit is contained in:
2026-03-10 18:48:27 +01:00
parent 8893a80c12
commit 71890e4cc2
6 changed files with 469 additions and 24 deletions

View File

@@ -635,17 +635,91 @@
/>
</label>
<label class="ui-form-field form-field--wide">
<div class="ui-form-field form-field--wide">
<span class="ui-form-caption">
Descrizione {{ languageLabels[activeContentLanguage] }}
</span>
<textarea
class="ui-form-control textarea-control textarea-control--large"
[(ngModel)]="productForm.descriptions[activeContentLanguage]"
[name]="'product-description-' + activeContentLanguage"
rows="6"
></textarea>
</label>
<div class="rich-text-field">
<div class="rich-text-toolbar" role="toolbar">
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescription('bold')"
title="Grassetto"
aria-label="Grassetto"
>
<strong>B</strong>
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescription('italic')"
title="Corsivo"
aria-label="Corsivo"
>
<em>I</em>
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescription('underline')"
title="Sottolineato"
aria-label="Sottolineato"
>
<u>U</u>
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescriptionList('unordered')"
title="Lista puntata"
aria-label="Lista puntata"
>
• Lista
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescriptionList('ordered')"
title="Lista numerata"
aria-label="Lista numerata"
>
1. Lista
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="clearDescriptionFormatting()"
title="Rimuovi formattazione"
aria-label="Rimuovi formattazione"
>
Tx
</button>
</div>
<div
#descriptionEditorRef
class="ui-form-control textarea-control textarea-control--large rich-text-editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
[attr.aria-label]="
'Descrizione ' + languageLabels[activeContentLanguage]
"
[innerHTML]="productForm.descriptions[activeContentLanguage]"
(input)="onDescriptionEditorInput($event)"
(blur)="onDescriptionEditorBlur($event)"
></div>
</div>
<span class="ui-form-caption">
Supporta grassetto, corsivo, liste puntate e numerate.
</span>
</div>
</div>
</section>

View File

@@ -252,6 +252,63 @@
min-height: 136px;
}
.rich-text-field {
display: grid;
gap: var(--space-2);
}
.rich-text-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rich-text-toolbar__button {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: #fff;
color: var(--color-text);
min-height: 2rem;
padding: 0.28rem 0.56rem;
font-size: 0.82rem;
cursor: pointer;
}
.rich-text-toolbar__button:hover {
border-color: #cbb88a;
background: #fffdf4;
}
.rich-text-editor {
overflow-y: auto;
padding: 0.62rem 0.72rem;
line-height: 1.62;
}
.rich-text-editor:focus-visible {
outline: none;
}
.rich-text-editor:empty::before {
content: "Scrivi la descrizione del prodotto...";
color: var(--color-text-muted);
}
.rich-text-editor p,
.rich-text-editor div {
margin: 0 0 0.62rem;
}
.rich-text-editor ul,
.rich-text-editor ol {
margin: 0 0 0.62rem 1.25rem;
padding: 0;
}
.rich-text-editor li + li {
margin-top: 0.22rem;
}
.toggle-row {
display: flex;
flex-wrap: wrap;

View File

@@ -115,6 +115,20 @@ const MAX_MODEL_FILE_SIZE_BYTES = 100 * 1024 * 1024;
const SHOP_LIST_PANEL_WIDTH_STORAGE_KEY = 'admin-shop-list-panel-width';
const MIN_LIST_PANEL_WIDTH_PERCENT = 32;
const MAX_LIST_PANEL_WIDTH_PERCENT = 68;
const RICH_TEXT_ALLOWED_TAGS = new Set([
'P',
'DIV',
'BR',
'STRONG',
'B',
'EM',
'I',
'U',
'UL',
'OL',
'LI',
'A',
]);
@Component({
selector: 'app-admin-shop',
@@ -128,6 +142,8 @@ export class AdminShopComponent implements OnInit, OnDestroy {
private readonly adminOperationsService = inject(AdminOperationsService);
@ViewChild('workspaceRef')
private readonly workspaceRef?: ElementRef<HTMLDivElement>;
@ViewChild('descriptionEditorRef')
private readonly descriptionEditorRef?: ElementRef<HTMLDivElement>;
readonly shopLanguages = SHOP_LANGUAGES;
readonly mediaLanguages = MEDIA_LANGUAGES;
@@ -525,6 +541,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
setActiveContentLanguage(language: ShopLanguage): void {
this.syncDescriptionFromEditor(
this.descriptionEditorRef?.nativeElement ?? null,
true,
);
this.activeContentLanguage = language;
}
@@ -536,7 +556,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
return (
!!this.productForm.names[language].trim() ||
!!this.productForm.excerpts[language].trim() ||
!!this.productForm.descriptions[language].trim()
this.hasMeaningfulRichText(this.productForm.descriptions[language])
);
}
@@ -568,6 +588,34 @@ export class AdminShopComponent implements OnInit, OnDestroy {
);
}
preventRichTextToolbarMouseDown(event: MouseEvent): void {
event.preventDefault();
}
onDescriptionEditorInput(event: Event): void {
const editor = event.target as HTMLDivElement | null;
this.syncDescriptionFromEditor(editor, false);
}
onDescriptionEditorBlur(event: Event): void {
const editor = event.target as HTMLDivElement | null;
this.syncDescriptionFromEditor(editor, true);
}
formatDescription(command: 'bold' | 'italic' | 'underline'): void {
this.applyDescriptionExecCommand(command);
}
formatDescriptionList(type: 'unordered' | 'ordered'): void {
this.applyDescriptionExecCommand(
type === 'unordered' ? 'insertUnorderedList' : 'insertOrderedList',
);
}
clearDescriptionFormatting(): void {
this.applyDescriptionExecCommand('removeFormat');
}
addMaterial(): void {
const nextMaterialCode = this.nextAvailableMaterialCode();
if (!nextMaterialCode) {
@@ -1253,10 +1301,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
fr: product.excerptFr ?? '',
},
descriptions: {
it: product.descriptionIt ?? '',
en: product.descriptionEn ?? '',
de: product.descriptionDe ?? '',
fr: product.descriptionFr ?? '',
it: this.normalizeDescriptionForEditor(product.descriptionIt),
en: this.normalizeDescriptionForEditor(product.descriptionEn),
de: this.normalizeDescriptionForEditor(product.descriptionDe),
fr: this.normalizeDescriptionForEditor(product.descriptionFr),
},
seoTitles: {
it: product.seoTitleIt ?? '',
@@ -1394,11 +1442,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
excerptEn: this.optionalValue(this.productForm.excerpts['en']),
excerptDe: this.optionalValue(this.productForm.excerpts['de']),
excerptFr: this.optionalValue(this.productForm.excerpts['fr']),
description: this.optionalValue(this.productForm.descriptions['it']),
descriptionIt: this.optionalValue(this.productForm.descriptions['it']),
descriptionEn: this.optionalValue(this.productForm.descriptions['en']),
descriptionDe: this.optionalValue(this.productForm.descriptions['de']),
descriptionFr: this.optionalValue(this.productForm.descriptions['fr']),
description: this.optionalRichTextValue(this.productForm.descriptions['it']),
descriptionIt: this.optionalRichTextValue(
this.productForm.descriptions['it'],
),
descriptionEn: this.optionalRichTextValue(
this.productForm.descriptions['en'],
),
descriptionDe: this.optionalRichTextValue(
this.productForm.descriptions['de'],
),
descriptionFr: this.optionalRichTextValue(
this.productForm.descriptions['fr'],
),
seoTitle: this.optionalValue(this.productForm.seoTitles['it']),
seoTitleIt: this.optionalValue(this.productForm.seoTitles['it']),
seoTitleEn: this.optionalValue(this.productForm.seoTitles['en']),
@@ -1760,6 +1816,189 @@ export class AdminShopComponent implements OnInit, OnDestroy {
return normalized ? normalized : undefined;
}
private optionalRichTextValue(value: string): string | undefined {
const normalized = this.normalizeRichTextStorageValue(value);
return normalized ? normalized : undefined;
}
private syncDescriptionFromEditor(
editor: HTMLDivElement | null,
sanitize: boolean,
): void {
if (!editor) {
return;
}
const currentLanguage = this.activeContentLanguage;
if (sanitize) {
const normalized = this.normalizeRichTextStorageValue(editor.innerHTML);
const safeHtml = normalized ?? '';
this.productForm.descriptions[currentLanguage] = safeHtml;
if (editor.innerHTML !== safeHtml) {
editor.innerHTML = safeHtml;
}
return;
}
this.productForm.descriptions[currentLanguage] = editor.innerHTML;
}
private applyDescriptionExecCommand(command: string): void {
const editor = this.descriptionEditorRef?.nativeElement ?? null;
if (!editor) {
return;
}
editor.focus();
document.execCommand(command, false);
this.syncDescriptionFromEditor(editor, false);
}
private normalizeDescriptionForEditor(value: string | null | undefined): string {
return this.normalizeRichTextStorageValue(value ?? '') ?? '';
}
private normalizeRichTextStorageValue(value: string): string | null {
const normalized = value.trim();
if (!normalized) {
return null;
}
const sanitized = this.containsHtmlMarkup(normalized)
? this.sanitizeRichTextHtml(normalized)
: this.plainTextToRichTextHtml(normalized);
const compact = sanitized.trim();
if (!compact || !this.hasMeaningfulRichText(compact)) {
return null;
}
return compact;
}
private containsHtmlMarkup(value: string): boolean {
return /<\/?[a-z][\s\S]*>/i.test(value);
}
private plainTextToRichTextHtml(value: string): string {
const normalized = value.replace(/\r\n?/g, '\n').trim();
if (!normalized) {
return '';
}
return normalized
.split(/\n{2,}/)
.map(
(paragraph) =>
`<p>${this.escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`,
)
.join('');
}
private sanitizeRichTextHtml(value: string): string {
const parser = new DOMParser();
const sourceDocument = parser.parseFromString(
`<body>${value}</body>`,
'text/html',
);
const outputDocument = parser.parseFromString('<body></body>', 'text/html');
const outputBody = outputDocument.body;
for (const child of Array.from(sourceDocument.body.childNodes)) {
const sanitizedNode = this.sanitizeRichTextNode(child, outputDocument);
if (sanitizedNode) {
outputBody.appendChild(sanitizedNode);
}
}
return outputBody.innerHTML;
}
private sanitizeRichTextNode(
node: Node,
outputDocument: Document,
): Node | DocumentFragment | null {
if (node.nodeType === Node.TEXT_NODE) {
return outputDocument.createTextNode(node.textContent ?? '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return null;
}
const sourceElement = node as HTMLElement;
const tagName = sourceElement.tagName.toUpperCase();
const childNodes = Array.from(sourceElement.childNodes)
.map((child) => this.sanitizeRichTextNode(child, outputDocument))
.filter((child): child is Node | DocumentFragment => child !== null);
if (!RICH_TEXT_ALLOWED_TAGS.has(tagName)) {
const fragment = outputDocument.createDocumentFragment();
for (const child of childNodes) {
fragment.appendChild(child);
}
return fragment;
}
const element = outputDocument.createElement(tagName.toLowerCase());
if (tagName === 'A') {
const href = this.sanitizeRichTextHref(sourceElement.getAttribute('href'));
if (href) {
element.setAttribute('href', href);
if (href.startsWith('http://') || href.startsWith('https://')) {
element.setAttribute('target', '_blank');
element.setAttribute('rel', 'noopener noreferrer');
}
}
}
for (const child of childNodes) {
element.appendChild(child);
}
if (tagName === 'A' && !element.textContent?.trim()) {
return null;
}
if ((tagName === 'UL' || tagName === 'OL') && !element.querySelector('li')) {
return null;
}
if (tagName === 'LI' && !element.textContent?.trim()) {
return null;
}
return element;
}
private sanitizeRichTextHref(rawHref: string | null): string | null {
const href = rawHref?.trim();
if (!href) {
return null;
}
const lowerHref = href.toLowerCase();
if (lowerHref.startsWith('/') || lowerHref.startsWith('#')) {
return href;
}
if (
lowerHref.startsWith('http://') ||
lowerHref.startsWith('https://') ||
lowerHref.startsWith('mailto:') ||
lowerHref.startsWith('tel:')
) {
return href;
}
return null;
}
private hasMeaningfulRichText(value: string): boolean {
return this.extractTextFromHtml(value).replace(/\u00a0/g, ' ').trim().length > 0;
}
private extractTextFromHtml(value: string): string {
const container = document.createElement('div');
container.innerHTML = value;
return container.textContent ?? '';
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
seoDescriptionLength(language: ShopLanguage): number {
return this.productForm.seoDescriptions[language].trim().length;
}

View File

@@ -121,7 +121,7 @@
<p class="excerpt">
{{
p.excerpt ||
p.description ||
descriptionPlainText(p.description) ||
("SHOP.EXCERPT_FALLBACK" | translate)
}}
</p>
@@ -304,10 +304,13 @@
</div>
</app-card>
@if (p.description) {
@if (descriptionPlainText(p.description)) {
<div class="description-block">
<h2>{{ "SHOP.DESCRIPTION_TITLE" | translate }}</h2>
<p>{{ p.description }}</p>
<div
class="description-block__content"
[innerHTML]="descriptionRichHtml(p.description)"
></div>
</div>
}
</section>

View File

@@ -209,8 +209,7 @@ h1 {
line-height: 1.06;
}
.excerpt,
.description-block p {
.excerpt {
margin: 0;
color: var(--color-text-muted);
line-height: 1.6;
@@ -380,11 +379,32 @@ h1 {
font-size: 1.45rem;
}
.description-block p {
.description-block__content {
color: var(--color-text-muted);
font-size: 1.06rem;
line-height: 1.75;
}
.description-block__content p,
.description-block__content div {
margin: 0 0 0.7rem;
}
.description-block__content p:last-child,
.description-block__content div:last-child {
margin-bottom: 0;
}
.description-block__content ul,
.description-block__content ol {
margin: 0 0 0.7rem 1.3rem;
padding: 0;
}
.description-block__content li + li {
margin-top: 0.22rem;
}
:host ::ng-deep app-card.purchase-shell .card-body {
padding: 0.95rem 1rem;
}

View File

@@ -469,6 +469,7 @@ export class ProductDetailComponent {
const description =
product.seoDescription ||
product.excerpt ||
this.extractTextFromRichContent(product.description) ||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
const robots =
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
@@ -500,6 +501,28 @@ export class ProductDetailComponent {
return String(variant?.variantLabel || '').trim() || 'Standard';
}
descriptionPlainText(description: string | null | undefined): string {
return this.extractTextFromRichContent(description) ?? '';
}
descriptionRichHtml(description: string | null | undefined): string {
const normalized = String(description ?? '').trim();
if (!normalized) {
return '';
}
if (this.containsHtmlMarkup(normalized)) {
return normalized;
}
return normalized
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
.map(
(paragraph) =>
`<p>${this.escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`,
)
.join('');
}
private materialKeyForVariant(
variant: ShopProductVariantOption | null,
): string | null {
@@ -597,6 +620,35 @@ export class ProductDetailComponent {
];
}
private extractTextFromRichContent(
value: string | null | undefined,
): string | null {
const normalized = String(value ?? '').trim();
if (!normalized) {
return null;
}
if (!this.containsHtmlMarkup(normalized)) {
return normalized;
}
const container = document.createElement('div');
container.innerHTML = normalized;
const text = (container.textContent ?? '').replace(/\u00a0/g, ' ').trim();
return text || null;
}
private containsHtmlMarkup(value: string): boolean {
return /<\/?[a-z][\s\S]*>/i.test(value);
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
private syncPublicUrl(product: ShopProductDetail): void {
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
const targetProductSlug = this.shopRouteService.productPathSegment(product);