feat(project-editor): syntax-highlighted code editor for HTML raw mode
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- shared app-code-editor: overlay textarea + highlighted <pre> layer, no external dependency (Monaco/CodeMirror) - tokenizeCss: selector/property/value/string/comment/at-rule aware, brace-depth state machine - tokenizeHtml: tags + comments colored, delegates <style> block content to tokenizeCss (that's where static-page CSS is actually authored) - marketplace-html-editor raw-code mode now uses app-code-editor instead of a plain textarea Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
14
src/app/shared/ui/code-editor/code-editor.component.html
Normal file
14
src/app/shared/ui/code-editor/code-editor.component.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="code-editor" [class.code-editor--invalid]="invalid()" [class.code-editor--focused]="focused()">
|
||||
<pre #highlightLayer class="code-editor-highlight" [attr.rows]="rows()"><code [innerHTML]="highlighted()"></code></pre>
|
||||
<textarea
|
||||
#textarea
|
||||
class="code-editor-input"
|
||||
[rows]="rows()"
|
||||
spellcheck="false"
|
||||
[value]="value()"
|
||||
(input)="onInput($event)"
|
||||
(scroll)="syncScroll()"
|
||||
(focus)="focused.set(true)"
|
||||
(blur)="focused.set(false)"
|
||||
></textarea>
|
||||
</div>
|
||||
87
src/app/shared/ui/code-editor/code-editor.component.scss
Normal file
87
src/app/shared/ui/code-editor/code-editor.component.scss
Normal file
@@ -0,0 +1,87 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color, #d3dad9);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-editor--invalid {
|
||||
border-color: var(--error-color, #991b1b);
|
||||
}
|
||||
|
||||
.code-editor--focused {
|
||||
border-color: #497671;
|
||||
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.15);
|
||||
}
|
||||
|
||||
.code-editor-highlight,
|
||||
.code-editor-input {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.code-editor-highlight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary, #1e3c38);
|
||||
background: #fbfcfc;
|
||||
}
|
||||
|
||||
.code-editor-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
resize: vertical;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: var(--text-primary, #1e3c38);
|
||||
}
|
||||
|
||||
.code-editor-input::selection {
|
||||
background: rgba(73, 118, 113, 0.25);
|
||||
}
|
||||
|
||||
:global(.cm-comment) {
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:global(.cm-string) {
|
||||
color: #2f7d5e;
|
||||
}
|
||||
|
||||
:global(.cm-tag) {
|
||||
color: #6d3fa8;
|
||||
}
|
||||
|
||||
:global(.cm-selector) {
|
||||
color: #b2452f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.cm-property) {
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
:global(.cm-value) {
|
||||
color: #1e3c38;
|
||||
}
|
||||
|
||||
:global(.cm-keyword) {
|
||||
color: #6d3fa8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.cm-punct) {
|
||||
color: var(--text-secondary, #5f6e6a);
|
||||
}
|
||||
39
src/app/shared/ui/code-editor/code-editor.component.ts
Normal file
39
src/app/shared/ui/code-editor/code-editor.component.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, computed, input, output, signal } from '@angular/core';
|
||||
import { renderHighlighted, tokenizeCss, tokenizeHtml } from './code-highlight';
|
||||
|
||||
export type CodeEditorLanguage = 'html' | 'css';
|
||||
|
||||
@Component({
|
||||
selector: 'app-code-editor',
|
||||
standalone: true,
|
||||
templateUrl: './code-editor.component.html',
|
||||
styleUrl: './code-editor.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CodeEditorComponent {
|
||||
readonly value = input('');
|
||||
readonly language = input<CodeEditorLanguage>('html');
|
||||
readonly rows = input(10);
|
||||
readonly invalid = input(false);
|
||||
|
||||
readonly valueChange = output<string>();
|
||||
|
||||
@ViewChild('textarea', { static: true }) private textarea!: ElementRef<HTMLTextAreaElement>;
|
||||
@ViewChild('highlightLayer', { static: true }) private highlightLayer!: ElementRef<HTMLElement>;
|
||||
|
||||
protected readonly highlighted = computed(() => {
|
||||
const tokens = this.language() === 'css' ? tokenizeCss(this.value()) : tokenizeHtml(this.value());
|
||||
return renderHighlighted(tokens) + '\n';
|
||||
});
|
||||
|
||||
protected readonly focused = signal(false);
|
||||
|
||||
protected onInput(event: Event): void {
|
||||
this.valueChange.emit((event.target as HTMLTextAreaElement).value);
|
||||
}
|
||||
|
||||
protected syncScroll(): void {
|
||||
this.highlightLayer.nativeElement.scrollTop = this.textarea.nativeElement.scrollTop;
|
||||
this.highlightLayer.nativeElement.scrollLeft = this.textarea.nativeElement.scrollLeft;
|
||||
}
|
||||
}
|
||||
167
src/app/shared/ui/code-editor/code-highlight.ts
Normal file
167
src/app/shared/ui/code-editor/code-highlight.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
export interface HighlightToken {
|
||||
text: string;
|
||||
cls: string | null;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-pass CSS tokenizer. Tracks brace depth + a selector/property/value
|
||||
* mode so the same bare-word token (e.g. `color`) is classified differently
|
||||
* depending on where it sits, without a full CSS parser.
|
||||
*/
|
||||
export function tokenizeCss(css: string): HighlightToken[] {
|
||||
const tokens: HighlightToken[] = [];
|
||||
let i = 0;
|
||||
let braceDepth = 0;
|
||||
let mode: 'selector' | 'property' | 'value' = 'selector';
|
||||
|
||||
while (i < css.length) {
|
||||
if (css.startsWith('/*', i)) {
|
||||
const end = css.indexOf('*/', i + 2);
|
||||
const stop = end === -1 ? css.length : end + 2;
|
||||
tokens.push({ text: css.slice(i, stop), cls: 'cm-comment' });
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
const ch = css[i];
|
||||
if (ch === '"' || ch === "'") {
|
||||
let j = i + 1;
|
||||
while (j < css.length && css[j] !== ch) {
|
||||
if (css[j] === '\\') j++;
|
||||
j++;
|
||||
}
|
||||
j = Math.min(j + 1, css.length);
|
||||
tokens.push({ text: css.slice(i, j), cls: 'cm-string' });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') {
|
||||
tokens.push({ text: '{', cls: 'cm-punct' });
|
||||
braceDepth++;
|
||||
mode = 'property';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '}') {
|
||||
tokens.push({ text: '}', cls: 'cm-punct' });
|
||||
braceDepth = Math.max(0, braceDepth - 1);
|
||||
mode = braceDepth > 0 ? 'property' : 'selector';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ':' && braceDepth > 0 && mode === 'property') {
|
||||
tokens.push({ text: ':', cls: 'cm-punct' });
|
||||
mode = 'value';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ';') {
|
||||
tokens.push({ text: ';', cls: 'cm-punct' });
|
||||
mode = braceDepth > 0 ? 'property' : mode;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',') {
|
||||
tokens.push({ text: ',', cls: 'cm-punct' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < css.length && !'{}:;,"\''.includes(css[j]) && !css.startsWith('/*', j)) {
|
||||
j++;
|
||||
}
|
||||
if (j === i) {
|
||||
j = i + 1;
|
||||
}
|
||||
const text = css.slice(i, j);
|
||||
const cls = mode === 'selector' ? 'cm-selector' : mode === 'property' ? (/^\s*@/.test(text) ? 'cm-keyword' : 'cm-property') : 'cm-value';
|
||||
tokens.push({ text, cls });
|
||||
i = j;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Colors a whole `<tag ...>` as one token, except quoted attribute values (strings). */
|
||||
function tokenizeTag(tagText: string): HighlightToken[] {
|
||||
const tokens: HighlightToken[] = [];
|
||||
const stringRe = /"[^"]*"|'[^']*'/g;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = stringRe.exec(tagText))) {
|
||||
if (match.index > last) {
|
||||
tokens.push({ text: tagText.slice(last, match.index), cls: 'cm-tag' });
|
||||
}
|
||||
tokens.push({ text: match[0], cls: 'cm-string' });
|
||||
last = stringRe.lastIndex;
|
||||
}
|
||||
if (last < tagText.length) {
|
||||
tokens.push({ text: tagText.slice(last), cls: 'cm-tag' });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML tokenizer: comments + tags get coarse coloring (fine-grained for
|
||||
* quoted attribute values), plain text stays uncolored. Content inside
|
||||
* `<style>...</style>` is delegated to the CSS tokenizer, since that is
|
||||
* where marketplace static pages actually author CSS.
|
||||
*/
|
||||
export function tokenizeHtml(html: string): HighlightToken[] {
|
||||
const tokens: HighlightToken[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < html.length) {
|
||||
if (html.startsWith('<!--', i)) {
|
||||
const end = html.indexOf('-->', i + 4);
|
||||
const stop = end === -1 ? html.length : end + 3;
|
||||
tokens.push({ text: html.slice(i, stop), cls: 'cm-comment' });
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
if (html[i] === '<') {
|
||||
let j = i + 1;
|
||||
let inStr: string | null = null;
|
||||
while (j < html.length) {
|
||||
const ch = html[j];
|
||||
if (inStr) {
|
||||
if (ch === inStr) inStr = null;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inStr = ch;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '>') {
|
||||
j++;
|
||||
break;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
const tagText = html.slice(i, j);
|
||||
tokens.push(...tokenizeTag(tagText));
|
||||
const isStyleOpenTag = /^<style(\s[^>]*)?>$/i.test(tagText);
|
||||
i = j;
|
||||
if (isStyleOpenTag) {
|
||||
const closeIdx = html.toLowerCase().indexOf('</style', i);
|
||||
const contentEnd = closeIdx === -1 ? html.length : closeIdx;
|
||||
tokens.push(...tokenizeCss(html.slice(i, contentEnd)));
|
||||
i = contentEnd;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let j = html.indexOf('<', i);
|
||||
if (j === -1) j = html.length;
|
||||
tokens.push({ text: html.slice(i, j), cls: null });
|
||||
i = j;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function renderHighlighted(tokens: HighlightToken[]): string {
|
||||
return tokens.map(token => (token.cls ? `<span class="${token.cls}">${escapeHtml(token.text)}</span>` : escapeHtml(token.text))).join('');
|
||||
}
|
||||
Reference in New Issue
Block a user