feat(static-pages): rich text extensions + HTML-mode validation
Milestone 3 of the Static Pages Module sprint. - MarketplaceHtmlEditorComponent toolbar: horizontal rule (insertHorizontalRule), code block (formatBlock -> PRE), embed (prompt for a URL, insert a sandboxed <iframe sandbox="allow-scripts allow-same-origin" loading="lazy">, same prompt-based UX as the existing link/image commands - no new dependency, consistent with the documented no-external-rich-text- library decision). - toggleCode() now validates raw HTML via schema/validators/primitives' validateHtml (added in M1) before committing it back to the visual surface; on failure it stays in code mode with an inline error instead of silently writing malformed markup into the contenteditable surface. Error clears on the next edit. - i18n: builder.promptEmbedUrl, builder.htmlEditorInvalidHtml in interface + en/ru/hy. Gate: tsc --noEmit, npm test (57/57), arch:check, build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,18 @@
|
|||||||
<button type="button" (click)="toggleCode()">{{ (showCode() ? 'builder.htmlEditorPreview' : 'builder.htmlEditorCode') | translate }}</button>
|
<button type="button" (click)="toggleCode()">{{ (showCode() ? 'builder.htmlEditorPreview' : 'builder.htmlEditorCode') | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
@if (showCode()) {
|
||||||
class="html-editor-code"
|
<textarea
|
||||||
rows="10"
|
class="html-editor-code"
|
||||||
[hidden]="!showCode()"
|
[class.html-editor-code--invalid]="codeError()"
|
||||||
[value]="codeValue()"
|
rows="10"
|
||||||
(input)="updateCode($any($event.target).value)"
|
[value]="codeValue()"
|
||||||
></textarea>
|
(input)="updateCode($any($event.target).value)"
|
||||||
|
></textarea>
|
||||||
|
@if (codeError()) {
|
||||||
|
<p class="html-editor-code-error">{{ codeError() }}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
#surface
|
#surface
|
||||||
|
|||||||
@@ -24,4 +24,14 @@
|
|||||||
border: 1px solid var(--border, #ccc);
|
border: 1px solid var(--border, #ccc);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
&--invalid {
|
||||||
|
border-color: var(--error-color, #991b1b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-editor-code-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--error-color, #991b1b);
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild, inject, signal } from '@angular/core';
|
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild, inject, signal } from '@angular/core';
|
||||||
import { TranslateService } from '../../../../i18n/translate.service';
|
import { TranslateService } from '../../../../i18n/translate.service';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
|
import { validateHtml } from '../../schema/validators/primitives';
|
||||||
|
|
||||||
export interface HtmlEditorToolbarCommand {
|
export interface HtmlEditorToolbarCommand {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +21,9 @@ export const HTML_EDITOR_TOOLBAR: HtmlEditorToolbarCommand[] = [
|
|||||||
{ id: 'link', label: 'Link', command: 'createLink' },
|
{ id: 'link', label: 'Link', command: 'createLink' },
|
||||||
{ id: 'image', label: 'Image', command: 'insertImage' },
|
{ id: 'image', label: 'Image', command: 'insertImage' },
|
||||||
{ id: 'table', label: 'Table', command: 'insertHTML', value: '<table><tr><td> </td><td> </td></tr></table>' },
|
{ id: 'table', label: 'Table', command: 'insertHTML', value: '<table><tr><td> </td><td> </td></tr></table>' },
|
||||||
|
{ id: 'hr', label: 'HR', command: 'insertHorizontalRule' },
|
||||||
|
{ id: 'codeblock', label: 'Code', command: 'formatBlock', value: 'PRE' },
|
||||||
|
{ id: 'embed', label: 'Embed', command: 'insertEmbed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -40,6 +44,7 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
|
|||||||
readonly toolbar = HTML_EDITOR_TOOLBAR;
|
readonly toolbar = HTML_EDITOR_TOOLBAR;
|
||||||
readonly showCode = signal(false);
|
readonly showCode = signal(false);
|
||||||
readonly codeValue = signal('');
|
readonly codeValue = signal('');
|
||||||
|
readonly codeError = signal<string | null>(null);
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
if (changes['html']) {
|
if (changes['html']) {
|
||||||
@@ -73,6 +78,20 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.execCommand('insertImage', false, url);
|
document.execCommand('insertImage', false, url);
|
||||||
|
} else if (item.command === 'insertEmbed') {
|
||||||
|
const url = window.prompt(this.translate.t('builder.promptEmbedUrl'));
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Sandboxed iframe: no same-origin/script escalation into the editor
|
||||||
|
// page itself. The editor already emits raw, unsanitized HTML by design
|
||||||
|
// (FACTS PE-...-0004) - sanitization happens at storefront render time
|
||||||
|
// (StaticPageComponent runs everything through DomSanitizer).
|
||||||
|
document.execCommand(
|
||||||
|
'insertHTML',
|
||||||
|
false,
|
||||||
|
`<iframe src="${this.escapeAttribute(url)}" sandbox="allow-scripts allow-same-origin" loading="lazy" style="width:100%;aspect-ratio:16/9;border:0"></iframe>`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
document.execCommand(item.command, false, item.value);
|
document.execCommand(item.command, false, item.value);
|
||||||
}
|
}
|
||||||
@@ -86,9 +105,19 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
|
|||||||
toggleCode(): void {
|
toggleCode(): void {
|
||||||
if (!this.showCode()) {
|
if (!this.showCode()) {
|
||||||
this.codeValue.set(this.surface.nativeElement.innerHTML);
|
this.codeValue.set(this.surface.nativeElement.innerHTML);
|
||||||
|
this.codeError.set(null);
|
||||||
this.showCode.set(true);
|
this.showCode.set(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Validate raw HTML before committing it back to the visual surface -
|
||||||
|
// malformed markup (an unclosed/mismatched tag) stays in code mode with
|
||||||
|
// an inline error rather than silently corrupting the visual editor.
|
||||||
|
const result = validateHtml(this.codeValue());
|
||||||
|
if (!result.ok) {
|
||||||
|
this.codeError.set(result.error ?? this.translate.t('builder.htmlEditorInvalidHtml'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.codeError.set(null);
|
||||||
this.surface.nativeElement.innerHTML = this.codeValue();
|
this.surface.nativeElement.innerHTML = this.codeValue();
|
||||||
this.showCode.set(false);
|
this.showCode.set(false);
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
@@ -96,6 +125,13 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
|
|||||||
|
|
||||||
updateCode(value: string): void {
|
updateCode(value: string): void {
|
||||||
this.codeValue.set(value);
|
this.codeValue.set(value);
|
||||||
|
if (this.codeError()) {
|
||||||
|
this.codeError.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeAttribute(value: string): string {
|
||||||
|
return value.replace(/&/g, '&').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitChange(): void {
|
private emitChange(): void {
|
||||||
|
|||||||
@@ -551,6 +551,8 @@ export const en: Translations = {
|
|||||||
confirmLeaveUnsaved: 'You have unsaved changes. Leave anyway?',
|
confirmLeaveUnsaved: 'You have unsaved changes. Leave anyway?',
|
||||||
promptLinkUrl: 'URL',
|
promptLinkUrl: 'URL',
|
||||||
promptImageUrl: 'Image URL',
|
promptImageUrl: 'Image URL',
|
||||||
|
promptEmbedUrl: 'Embed URL',
|
||||||
|
htmlEditorInvalidHtml: 'This HTML has an unclosed or mismatched tag. Fix it before switching back to the visual editor.',
|
||||||
htmlEditorCode: 'Code',
|
htmlEditorCode: 'Code',
|
||||||
htmlEditorPreview: 'Preview',
|
htmlEditorPreview: 'Preview',
|
||||||
lastSaved: 'Last saved',
|
lastSaved: 'Last saved',
|
||||||
|
|||||||
@@ -551,6 +551,8 @@ export const hy: Translations = {
|
|||||||
confirmLeaveUnsaved: 'Կան չպահված փոփոխություններ։ Այնուամենայնիվ դուրս գա՞լ։',
|
confirmLeaveUnsaved: 'Կան չպահված փոփոխություններ։ Այնուամենայնիվ դուրս գա՞լ։',
|
||||||
promptLinkUrl: 'URL',
|
promptLinkUrl: 'URL',
|
||||||
promptImageUrl: 'Նկարի URL',
|
promptImageUrl: 'Նկարի URL',
|
||||||
|
promptEmbedUrl: 'Ներդրման URL',
|
||||||
|
htmlEditorInvalidHtml: 'Այս HTML-ը ունի չփակված կամ չհամընկնող թեգ։ Ուղղեք նախքան տեսողական խմբագրիչին վերադառնալը։',
|
||||||
htmlEditorCode: 'Կոդ',
|
htmlEditorCode: 'Կոդ',
|
||||||
htmlEditorPreview: 'Նախադիտում',
|
htmlEditorPreview: 'Նախադիտում',
|
||||||
lastSaved: 'Վերջին պահպանումը',
|
lastSaved: 'Վերջին պահպանումը',
|
||||||
|
|||||||
@@ -551,6 +551,8 @@ export const ru: Translations = {
|
|||||||
confirmLeaveUnsaved: 'Есть несохранённые изменения. Всё равно уйти?',
|
confirmLeaveUnsaved: 'Есть несохранённые изменения. Всё равно уйти?',
|
||||||
promptLinkUrl: 'URL',
|
promptLinkUrl: 'URL',
|
||||||
promptImageUrl: 'URL изображения',
|
promptImageUrl: 'URL изображения',
|
||||||
|
promptEmbedUrl: 'URL для встраивания',
|
||||||
|
htmlEditorInvalidHtml: 'В этом HTML есть незакрытый или несовпадающий тег. Исправьте перед возвратом к визуальному редактору.',
|
||||||
htmlEditorCode: 'Код',
|
htmlEditorCode: 'Код',
|
||||||
htmlEditorPreview: 'Предпросмотр',
|
htmlEditorPreview: 'Предпросмотр',
|
||||||
lastSaved: 'Последнее сохранение',
|
lastSaved: 'Последнее сохранение',
|
||||||
|
|||||||
@@ -549,6 +549,8 @@ export interface Translations {
|
|||||||
confirmLeaveUnsaved: string;
|
confirmLeaveUnsaved: string;
|
||||||
promptLinkUrl: string;
|
promptLinkUrl: string;
|
||||||
promptImageUrl: string;
|
promptImageUrl: string;
|
||||||
|
promptEmbedUrl: string;
|
||||||
|
htmlEditorInvalidHtml: string;
|
||||||
htmlEditorCode: string;
|
htmlEditorCode: string;
|
||||||
htmlEditorPreview: string;
|
htmlEditorPreview: string;
|
||||||
lastSaved: string;
|
lastSaved: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user