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>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
class="html-editor-code"
|
||||
rows="10"
|
||||
[hidden]="!showCode()"
|
||||
[value]="codeValue()"
|
||||
(input)="updateCode($any($event.target).value)"
|
||||
></textarea>
|
||||
@if (showCode()) {
|
||||
<textarea
|
||||
class="html-editor-code"
|
||||
[class.html-editor-code--invalid]="codeError()"
|
||||
rows="10"
|
||||
[value]="codeValue()"
|
||||
(input)="updateCode($any($event.target).value)"
|
||||
></textarea>
|
||||
@if (codeError()) {
|
||||
<p class="html-editor-code-error">{{ codeError() }}</p>
|
||||
}
|
||||
}
|
||||
|
||||
<div
|
||||
#surface
|
||||
|
||||
@@ -24,4 +24,14 @@
|
||||
border: 1px solid var(--border, #ccc);
|
||||
border-radius: 4px;
|
||||
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 { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { validateHtml } from '../../schema/validators/primitives';
|
||||
|
||||
export interface HtmlEditorToolbarCommand {
|
||||
id: string;
|
||||
@@ -20,6 +21,9 @@ export const HTML_EDITOR_TOOLBAR: HtmlEditorToolbarCommand[] = [
|
||||
{ id: 'link', label: 'Link', command: 'createLink' },
|
||||
{ id: 'image', label: 'Image', command: 'insertImage' },
|
||||
{ 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({
|
||||
@@ -40,6 +44,7 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
|
||||
readonly toolbar = HTML_EDITOR_TOOLBAR;
|
||||
readonly showCode = signal(false);
|
||||
readonly codeValue = signal('');
|
||||
readonly codeError = signal<string | null>(null);
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['html']) {
|
||||
@@ -73,6 +78,20 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
document.execCommand(item.command, false, item.value);
|
||||
}
|
||||
@@ -86,9 +105,19 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
|
||||
toggleCode(): void {
|
||||
if (!this.showCode()) {
|
||||
this.codeValue.set(this.surface.nativeElement.innerHTML);
|
||||
this.codeError.set(null);
|
||||
this.showCode.set(true);
|
||||
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.showCode.set(false);
|
||||
this.emitChange();
|
||||
@@ -96,6 +125,13 @@ export class MarketplaceHtmlEditorComponent implements OnChanges, AfterViewInit
|
||||
|
||||
updateCode(value: string): void {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user