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
2 changes: 0 additions & 2 deletions src/app/components/repo-browser/repo-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ export class RepoBrowserComponent implements OnInit {
readonly initializing = signal<string | null>(null);
readonly initResult = signal<{ repo: string; pr: GitHubPullRequest } | null>(null);



/** Emitted when user wants to use manual form instead */
readonly manualConnect = output<void>();

Expand Down
25 changes: 24 additions & 1 deletion src/app/components/section-editor/section-editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@
</header>

<div class="section-body" role="region" aria-label="Markdown editor">
<div #sectionEditorHost class="editor-host"></div>
@if (hasTable()) {
<!-- Structured editing mode: forms for tables, textareas for text -->
<div class="structured-content">
@for (block of contentBlocks(); track $index; let blockIdx = $index) {
@if (block.type === 'table' && block.table) {
<app-table-editor
[table]="block.table"
(tableChange)="onTableChange(blockIdx, $event)"
/>
} @else if (block.type === 'text') {
<textarea
class="text-block"
[ngModel]="block.text ?? ''"
(ngModelChange)="onTextBlockChange(blockIdx, $event)"
placeholder="Enter text..."
rows="3"
></textarea>
}
}
</div>
} @else {
<!-- CodeMirror mode for sections without tables -->
<div #sectionEditorHost class="editor-host"></div>
}
</div>
</section>
35 changes: 35 additions & 0 deletions src/app/components/section-editor/section-editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,38 @@
height: 100%;
}
}

/* Structured editing mode */
.structured-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px;
height: 100%;
overflow-y: auto;
}

.text-block {
width: 100%;
min-height: 60px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
line-height: 1.6;
resize: vertical;

&:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-ring);
}

&::placeholder {
color: var(--text-muted);
font-style: italic;
}
}
80 changes: 71 additions & 9 deletions src/app/components/section-editor/section-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
effect,
OnDestroy,
signal,
computed,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { EditorState } from '@codemirror/state';
Expand All @@ -16,11 +17,18 @@ import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
import { type SpecSection } from '../../models/spec.model';
import {
type ContentBlock,
type MarkdownTable,
parseContentBlocks,
serializeContentBlocks,
} from '../../models/markdown-table';
import { TableEditorComponent } from '../table-editor/table-editor';

@Component({
selector: 'app-section-editor',
standalone: true,
imports: [FormsModule],
imports: [FormsModule, TableEditorComponent],
templateUrl: './section-editor.html',
styleUrl: './section-editor.scss',
})
Expand All @@ -30,32 +38,61 @@ export class SectionEditorComponent implements OnDestroy {
readonly totalSections = input.required<number>();
readonly contentChange = output<string>();
readonly headingChange = output<string>();
readonly navigate = output<number>(); // emit index to navigate to
readonly navigate = output<number>();

private readonly editorHost = viewChild<ElementRef<HTMLDivElement>>('sectionEditorHost');
private view: EditorView | null = null;
private suppressUpdate = false;
private suppressBlockParse = false;
private currentIndex = -1;

protected readonly headingValue = signal('');

/** Parsed content blocks for structured editing mode */
protected readonly contentBlocks = signal<ContentBlock[]>([]);

/** Whether this section has any tables (and should use structured mode) */
protected readonly hasTable = computed(() =>
this.contentBlocks().some((b) => b.type === 'table'),
);

constructor() {
effect(() => {
const host = this.editorHost()?.nativeElement;
if (!host) return;

const sec = this.section();
const idx = this.sectionIndex();

// If section changed, destroy old editor and create new one
// Parse content blocks on every section change (unless change came from structured editor)
if (this.suppressBlockParse) {
this.suppressBlockParse = false;
} else {
const blocks = parseContentBlocks(sec.content);
this.contentBlocks.set(blocks);
}

this.headingValue.set(sec.heading);

// If section changed, reset
if (this.currentIndex !== idx) {
this.destroyEditor();
this.currentIndex = idx;
}
});

this.headingValue.set(sec.heading);
// Separate effect for CodeMirror — only runs when NOT in structured mode
effect(() => {
const host = this.editorHost()?.nativeElement;
if (!host) return; // host doesn't exist (structured mode or not rendered yet)

if (this.view) {
const sec = this.section();
const idx = this.sectionIndex();

if (this.hasTable()) {
this.destroyEditor();
return;
}

// Same index — update existing editor if needed
if (this.view && this.currentIndex === idx) {
if (!this.suppressUpdate) {
const current = this.view.state.doc.toString();
if (current !== sec.content) {
Expand All @@ -68,6 +105,8 @@ export class SectionEditorComponent implements OnDestroy {
return;
}

this.destroyEditor();

this.view = new EditorView({
state: EditorState.create({
doc: sec.content,
Expand Down Expand Up @@ -107,6 +146,29 @@ export class SectionEditorComponent implements OnDestroy {
this.view = null;
}

/** Emit full serialized content from structured blocks */
private emitFromBlocks(blocks: ContentBlock[]): void {
this.contentBlocks.set(blocks);
const content = serializeContentBlocks(blocks);
this.suppressUpdate = true;
this.suppressBlockParse = true;
this.contentChange.emit(content);
}

protected onTableChange(blockIndex: number, table: MarkdownTable): void {
const blocks = this.contentBlocks().map((b, i) =>
i === blockIndex ? { ...b, table } : b,
);
this.emitFromBlocks(blocks);
}

protected onTextBlockChange(blockIndex: number, text: string): void {
const blocks = this.contentBlocks().map((b, i) =>
i === blockIndex ? { ...b, text } : b,
);
this.emitFromBlocks(blocks);
}

protected onHeadingChange(value: string): void {
this.headingValue.set(value);
this.headingChange.emit(value);
Expand All @@ -117,7 +179,7 @@ export class SectionEditorComponent implements OnDestroy {
if (idx > 0) {
this.navigate.emit(idx - 1);
} else {
this.navigate.emit(-1); // go to frontmatter
this.navigate.emit(-1);
}
}

Expand Down
50 changes: 50 additions & 0 deletions src/app/components/table-editor/table-editor.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<div class="table-editor">
<div class="table-scroll">
<table>
<thead>
<tr>
@for (header of table().headers; track $index) {
<th>{{ header }}</th>
}
<th class="actions-col"></th>
</tr>
</thead>
<tbody>
@for (row of table().rows; track $index; let rowIdx = $index) {
<tr>
@for (cell of row; track $index; let colIdx = $index) {
<td>
<input
type="text"
class="cell-input"
[ngModel]="cell"
(ngModelChange)="onCellChange(rowIdx, colIdx, $event)"
[placeholder]="table().headers[colIdx]"
/>
</td>
}
<td class="actions-cell">
<div class="row-actions">
@if (rowIdx > 0) {
<button class="btn-icon" (click)="moveRowUp(rowIdx)" title="Move up">&#8593;</button>
}
@if (rowIdx < table().rows.length - 1) {
<button class="btn-icon" (click)="moveRowDown(rowIdx)" title="Move down">&#8595;</button>
}
<button class="btn-icon btn-danger" (click)="removeRow(rowIdx)" title="Remove row">&times;</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>

@if (isEmpty()) {
<div class="empty-state">No rows yet. Add one below.</div>
}

<div class="table-footer">
<button class="btn btn-sm" (click)="addRow()">+ Add Row</button>
</div>
</div>
122 changes: 122 additions & 0 deletions src/app/components/table-editor/table-editor.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
.table-editor {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
}

.table-scroll {
overflow-x: auto;
}

table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}

thead {
background: var(--surface-alt);

th {
padding: 8px 12px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
}

tbody {
tr {
border-bottom: 1px solid var(--border);

&:last-child {
border-bottom: none;
}

&:hover {
background: var(--hover);
}
}

td {
padding: 2px 4px;
vertical-align: middle;
}
}

.cell-input {
width: 100%;
min-width: 80px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;

&:hover {
border-color: var(--border);
}

&:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
box-shadow: 0 0 0 2px var(--accent-ring);
}

&::placeholder {
color: var(--text-muted);
font-family: inherit;
font-style: italic;
opacity: 0.5;
}
}

.actions-col {
width: 80px;
}

.actions-cell {
width: 80px;
}

.row-actions {
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;

tr:hover & {
opacity: 1;
}
}

.btn-danger {
&:hover {
color: var(--status-error) !important;
background: var(--status-error-bg) !important;
}
}

.empty-state {
padding: 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}

.table-footer {
padding: 8px 12px;
border-top: 1px solid var(--border);
background: var(--surface-alt);
}
Loading