feature/markdown-syntax-check-linux-presenter #8

Merged
brenno merged 2 commits from feature/markdown-syntax-check-linux-presenter into main 2026-06-15 22:09:40 +00:00
14 changed files with 1661 additions and 157 deletions
Showing only changes of commit 3c8eda6fd9 - Show all commits

View file

@ -19,7 +19,7 @@ Built with Flutter for macOS, Windows, and Linux.
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata. The library can filter images without tags and clean up md5-identical duplicates (merging their tags/captions and repointing every deck — open or on disk — to the kept file).
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. Paste a spreadsheet selection (or CSV / a markdown table) into a table cell to fill the whole grid. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. Paste a spreadsheet selection (or CSV / a markdown table) into a table cell to fill the whole grid. **Markdown mode** edits the whole deck as raw Marp text, with a structural syntax check (line highlights, optional **Apply anyway**) before switching back. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
- **Accessibility** — WCAG 2.1-oriented: interface text scaling up to 200%, keyboard-operable panel divider and dialogs, screen-reader labels for slides and charts (charts read out their data), and slide-change announcements while presenting.
- **Crash recovery** — automatic snapshots so work survives an unexpected exit.
- **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).

View file

@ -15,7 +15,8 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
```
lib/
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
services/ # markdown, file, export, classification_policy, image, caption,
services/ # markdown, markdown_validator, file, export, classification_policy,
# image, caption,
# description, image_dedup (md5 duplicates),
# image_reference (.md rewrites), recovery, rasterizer,
# marp_html, annotation_codec, rehearsal_controller
@ -48,6 +49,12 @@ lib/
by their `_class` and parsed separately (their fenced block would otherwise
confuse the generic line parser).
`MarkdownValidator` (`lib/services/markdown_validator.dart`) performs a
structural pre-flight before applying raw markdown in the editor: it reports line-
anchored errors/warnings for the same shapes the parser expects (front matter,
slide separators, `_class`, fences, OciDeck HTML fragments, chart JSON, etc.).
See `docs/FILE_FORMAT.md` §10 and `docs/USER_GUIDE.md` (Markdown mode).
This service is heavily covered by the round-trip tests — treat it as the
source-of-truth for the file format and keep `FILE_FORMAT.md` in sync.

View file

@ -497,3 +497,58 @@ genegeerd, op presenter-notities na):
- **Voorwaartse migratie:** ontbrekende front-matter-velden en
stijlprofiel-velden vallen terug op standaardwaarden, en het ontbreken van het
`no-footer`-token betekent (voor oudere bestanden) "footer zichtbaar".
---
## 10. Markdown-modus en syntaxcontrole
In de editor schakelt het code-icoon in de werkbalk naar **Markdown-modus**: de
hele presentatie wordt als één Marp-markdownbestand getoond (dezelfde structuur
als op schijf). **Toepassen** parsed de tekst terug naar de getype slides;
**Annuleren** keert terug zonder wijzigingen door te voeren.
### Wanneer controleren?
- **Controleren** — op elk moment tijdens het bewerken; resultaten in een
samenvattingsbalk, met uitklapbare lijst. Regelnummers links worden rood
(fout) of geel (waarschuwing) gemarkeerd; klik op een melding om naar die regel
te springen.
- **Toepassen** — voert altijd eerst de controle uit. Bij bevindingen verschijnt
een dialoog met **Terug naar editor**, **Annuleren** of **Toch toepassen**.
De controle is **structureel**: hij volgt dezelfde regels als `MarkdownService`
(front matter, `\n---\n` als scheiding, `_class`-commentaar, fenced blocks en de
HTML-fragmenten die OciDeck zelf genereert). Geldige Marp-syntax die OciDeck
niet modelleert wordt niet gerapporteerd.
### Uitgevoerde controles
| Onderdeel | Ernst | Controle |
| --- | --- | --- |
| **Document** | waarschuwing | Presentatie is leeg. |
| **Document** | fout | Geen slide-inhoud na front matter. |
| **Document** | fout | `parseDeck` faalt volledig (`null`). |
| **Front matter** | fout | Openings-`---` zonder afsluitende `---`-regel. |
| **Front matter** | waarschuwing | Regel zonder `sleutel: waarde`-vorm. |
| **Front matter** | fout | Onbekende `tlp:`-waarde. |
| **Commentaar** | fout | `<!--` zonder `-->` op dezelfde regel. |
| **Commentaar** | waarschuwing | Commentaar zonder `_class:`, `_style:`, `ocideck_…`, `skip`, `tlp:` of `advance:`. |
| **Codeblokken** | fout | Oneven aantal ` ``` `-regels (niet afgesloten). |
| **`_class`** | fout | Malformed `<!-- _class: … -->`. |
| **`_class`** | waarschuwing | Onbekend token in `_class` (bekend: `title`, `section`, `two-bullets`, `split`, `quote`, `video`, `table`, `code`, `chart`, `logo-safe`, `no-logo`, `no-footer`). |
| **Slide-metadata** | fout | Onbekende `<!-- tlp: … -->`, niet-numerieke `<!-- advance: … -->`, of ongeldige `<!-- ocideck_list_style: … -->` (`bullets`, `numbered`, `checklist`). |
| **Twee kolommen** | fout | Ongeldige base64/JSON in `ocideck_two_bullets_*`-commentaren. |
| **Afbeeldingen** | fout | `![…](…` zonder sluitende `)`. |
| **Video/audio** | fout | Onvolledige `<video>`/`<audio>`-tag, of `<video>` zonder `src="…"`. |
| **`code`-slide** | fout | Geen afgesloten fenced ```-blok. |
| **`chart`-slide** | fout | Geen ` ```chart `-blok, niet afgesloten, of ongeldige JSON (geen `{…}`-object). |
| **`chart`-slide** | waarschuwing | Lege JSON in een afgesloten ` ```chart `-blok. |
| **`split`-slide** | fout | Ontbrekende of niet-afgesloten `<div class="split-text">` / `split-image`. |
| **`two-bullets`-slide** | fout | Ontbrekende of niet-afgesloten `<div class="ocideck-two-bullets">`. |
| **`table`-slide** | waarschuwing | Geen tabelregels. |
| **`table`-slide** | fout | Geen scheidingsrij (`\| --- \|`) of tweede rij is geen geldige GFM-scheiding. |
| **HTML** | fout | Ongebalanceerde `<div>`/ `</div>` binnen een slide. |
Implementatie: `lib/services/markdown_validator.dart`; tests:
`test/markdown_validator_test.dart`. Zie ook [`USER_GUIDE.md`](USER_GUIDE.md) (§
Markdown mode).

View file

@ -194,6 +194,70 @@ OciDeck aims for WCAG 2.1 in the editor:
title", including the skipped state), charts read out their data as a text
alternative, and the fullscreen presenter announces every slide change.
## Markdown mode
The toolbar code icon switches the editor to **Markdown mode**: the whole deck is
shown as one Marp Markdown document (the same structure OciDeck writes to disk).
Use this for bulk edits, copy-paste from another tool, or tweaks that are faster
in raw text. Switch back with **Apply** (to parse the text back into typed slides)
or **Cancel** (discard your edits and return to the visual editor).
### Syntax check
Markdown mode includes a **syntax check** that validates your text against what
OciDeck's parser (`MarkdownService`) can read reliably. Broken structure often
does not fail loudly — the deck may load with the wrong slide types or missing
content — so the check catches problems before you apply.
- **Check** — run validation at any time while editing. Results appear in a
summary bar; expand it for a list of issues. Line numbers in the gutter are
highlighted (red = error, amber = warning). Click an issue or a line number to
jump to that line.
- **Apply** — always runs the check first. If anything is found, a dialog lists
the problems and offers **Back to editor**, **Cancel**, or **Apply anyway**.
Choosing **Apply anyway** proceeds despite the warnings (you may still see the
existing "Markdown could not be processed" banner if parsing returns `null`).
The check is **structural**, not a full Marp linter: it mirrors OciDeck's own
splitting rules (front matter, `\n---\n` slide separators, `_class` comments,
fenced blocks, and the HTML fragments OciDeck generates). Valid Marp that OciDeck
does not model (e.g. arbitrary directives) is not reported.
#### Checks performed
Issues are reported with a **line number**, a **severity**, and a short message.
| Area | Severity | What is checked |
| --- | --- | --- |
| **Document** | warning | The file is empty. |
| **Document** | error | No slide content after the front matter. |
| **Document** | error | `MarkdownService.parseDeck` returns `null` (unrecoverable parse failure). |
| **Front matter** | error | Opening `---` without a closing `---` line. |
| **Front matter** | warning | A line inside the block is not `key: value`. |
| **Front matter** | error | `tlp:` value is not a known key (`clear`, `green`, `amber`, `amber+strict`, `red`, or empty/`none`). |
| **Comments** | error | `<!--` without a matching `-->` on the same line. |
| **Comments** | warning | A comment looks like metadata but lacks `_class:`, `_style:`, `ocideck_…`, `skip`, `tlp:`, or `advance:`. |
| **Fenced code** | error | An odd number of ` ``` ` lines in the file (unclosed fence). |
| **Slide class** | error | A malformed `<!-- _class: … -->` (present but not parseable). |
| **Slide class** | warning | An unknown token in `_class` (only `title`, `section`, `two-bullets`, `split`, `quote`, `video`, `table`, `code`, `chart`, `logo-safe`, `no-logo`, `no-footer` are recognised; other tokens are kept as custom CSS classes but may change auto-detection). |
| **Per-slide metadata** | error | `<!-- tlp: … -->` with an unknown level. |
| **Per-slide metadata** | error | `<!-- advance: … -->` where the value is not a number. |
| **Per-slide metadata** | error | `<!-- ocideck_list_style: … -->` not `bullets`, `numbered`, or `checklist`. |
| **Two-column bullets** | error | `ocideck_two_bullets_left/right` or `*_title` comments with invalid base64/JSON. |
| **Images** | error | `![…](…` without a closing `)`. |
| **Video / audio** | error | `<video>` / `<audio>` tag incomplete, or `<video>` without `src="…"`. |
| **`code` slides** | error | `_class: code` but fewer than two fence lines (no closed fenced block). |
| **`chart` slides** | error | Missing ` ```chart ` block, unclosed fence, or JSON that is not a valid `{…}` object. |
| **`chart` slides** | warning | Empty JSON inside a closed ` ```chart ` block. |
| **`split` slides** | error | Missing or unclosed `<div class="split-text">` or `<div class="split-image">`. |
| **`two-bullets` slides** | error | Missing or unclosed `<div class="ocideck-two-bullets">`. |
| **`table` slides** | warning | `_class: table` but no `\| … \|` rows. |
| **`table` slides** | error | Table present but no separator row (`\| --- \|`), or the second row is not a valid GFM separator. |
| **HTML layout** | error | Unbalanced `<div>` / `</div>` within a slide (extra closing tag, or an opening tag left open). |
Implementation: `lib/services/markdown_validator.dart` (unit tests in
`test/markdown_validator_test.dart`).
## Theming and language
- **Style profiles** control deck colours (including the source-code background,

View file

@ -1056,6 +1056,17 @@ const _dutchSourceStrings = {
'Toepassen': 'Apply',
'Markdown kon niet worden verwerkt. Controleer de syntax.':
'Markdown could not be processed. Check the syntax.',
'Controleren': 'Check syntax',
'Syntaxproblemen gevonden': 'Syntax issues found',
'De markdown bevat': 'The markdown contains',
'fout(en) en': 'error(s) and',
'waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.':
'warning(s). Slides may not be parsed correctly.',
'Terug naar editor': 'Back to editor',
'Toch toepassen': 'Apply anyway',
'Geen syntaxproblemen gevonden': 'No syntax issues found',
'fout(en),': 'error(s),',
'waarschuwing(en)': 'warning(s)',
'Afbeelding kiezen': 'Choose image',
'Afbeeldingen laden…': 'Loading images…',
'Sluiten (Esc)': 'Close (Esc)',

View file

@ -0,0 +1,36 @@
enum MarkdownValidationSeverity { error, warning }
class MarkdownValidationIssue {
final int line;
final MarkdownValidationSeverity severity;
final String message;
const MarkdownValidationIssue({
required this.line,
required this.severity,
required this.message,
});
@override
String toString() => 'L$line: $message';
}
class MarkdownValidationResult {
final List<MarkdownValidationIssue> issues;
const MarkdownValidationResult(this.issues);
bool get isValid => !issues.any(
(issue) => issue.severity == MarkdownValidationSeverity.error,
);
bool get hasIssues => issues.isNotEmpty;
int get errorCount => issues
.where((i) => i.severity == MarkdownValidationSeverity.error)
.length;
int get warningCount => issues
.where((i) => i.severity == MarkdownValidationSeverity.warning)
.length;
}

View file

@ -0,0 +1,704 @@
import 'dart:convert';
import '../models/deck.dart';
import '../models/markdown_validation.dart';
import 'markdown_service.dart';
/// Validates deck markdown against what [MarkdownService] can parse reliably.
class MarkdownValidator {
static const _knownClassTokens = {
'title',
'section',
'two-bullets',
'split',
'quote',
'video',
'table',
'code',
'chart',
'logo-safe',
'no-logo',
'no-footer',
};
static const _validListStyles = {'bullets', 'numbered', 'checklist'};
MarkdownValidationResult validate(String markdown) {
final issues = <MarkdownValidationIssue>[];
if (markdown.trim().isEmpty) {
issues.add(
const MarkdownValidationIssue(
line: 1,
severity: MarkdownValidationSeverity.warning,
message: 'De presentatie is leeg.',
),
);
return MarkdownValidationResult(issues);
}
final lines = markdown.split('\n');
_validateFrontMatter(lines, issues);
_validateHtmlComments(lines, issues);
_validateFenceBalance(lines, issues);
final body = _stripFrontMatter(markdown);
final bodyStartLine = markdown.length - body.length > 0
? markdown.substring(0, markdown.length - body.length).split('\n').length
: 1;
final blocks = body.split(RegExp(r'\n---\n'));
if (blocks.every((block) => block.trim().isEmpty)) {
issues.add(
MarkdownValidationIssue(
line: bodyStartLine,
severity: MarkdownValidationSeverity.error,
message: 'Geen slides gevonden.',
),
);
}
var blockStartLine = bodyStartLine;
for (var i = 0; i < blocks.length; i++) {
final block = blocks[i].trim();
if (block.isEmpty) {
blockStartLine += 1;
continue;
}
_validateSlideBlock(
block: block,
slideNumber: i + 1,
startLine: blockStartLine,
issues: issues,
);
blockStartLine += block.split('\n').length + 1;
}
if (MarkdownService().parseDeck(markdown) == null) {
issues.add(
const MarkdownValidationIssue(
line: 1,
severity: MarkdownValidationSeverity.error,
message:
'De markdown kon niet worden ingelezen. Controleer de structuur.',
),
);
}
issues.sort((a, b) => a.line.compareTo(b.line));
return MarkdownValidationResult(issues);
}
void _validateFrontMatter(
List<String> lines,
List<MarkdownValidationIssue> issues,
) {
if (lines.isEmpty || lines.first != '---') return;
final closingIndex = _indexOfFrontMatterClose(lines);
if (closingIndex == -1) {
issues.add(
const MarkdownValidationIssue(
line: 1,
severity: MarkdownValidationSeverity.error,
message:
'Front matter is niet afgesloten. Sluit af met een regel `---`.',
),
);
return;
}
for (var i = 1; i < closingIndex; i++) {
final line = lines[i].trim();
if (line.isEmpty || line.startsWith('#')) continue;
if (!line.contains(':')) {
issues.add(
MarkdownValidationIssue(
line: i + 1,
severity: MarkdownValidationSeverity.warning,
message: 'Front matter-regel heeft geen sleutel:waarde-vorm.',
),
);
continue;
}
final key = line.substring(0, line.indexOf(':')).trim();
if (key == 'tlp') {
final value = line.substring(line.indexOf(':') + 1).trim();
if (!_isValidTlpKey(value)) {
issues.add(
MarkdownValidationIssue(
line: i + 1,
severity: MarkdownValidationSeverity.error,
message:
'Onbekend TLP-niveau "$value". Gebruik clear, green, amber, amber+strict of red.',
),
);
}
}
}
}
int _indexOfFrontMatterClose(List<String> lines) {
for (var i = 1; i < lines.length; i++) {
if (lines[i] == '---') return i;
}
return -1;
}
String _stripFrontMatter(String markdown) {
if (!markdown.startsWith('---\n')) return markdown;
final end = markdown.indexOf('\n---\n', 4);
if (end == -1) return markdown;
return markdown.substring(end + 5).trim();
}
void _validateHtmlComments(
List<String> lines,
List<MarkdownValidationIssue> issues,
) {
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
final openCount = '<!--'.allMatches(line).length;
final closeCount = '-->'.allMatches(line).length;
if (openCount > closeCount) {
issues.add(
MarkdownValidationIssue(
line: i + 1,
severity: MarkdownValidationSeverity.error,
message: 'HTML-commentaar is niet afgesloten met `-->`.',
),
);
}
final classLike = RegExp(r'<!--\s*([^_][^>]*?)-->').firstMatch(line);
if (classLike != null &&
!line.contains('_class:') &&
!line.contains('ocideck_') &&
!line.contains('_style:') &&
classLike.group(1)?.trim() != 'skip' &&
!RegExp(r'^tlp:\s*\S').hasMatch(classLike.group(1)!.trim()) &&
!RegExp(r'^advance:\s*[\d.]+').hasMatch(classLike.group(1)!.trim())) {
issues.add(
MarkdownValidationIssue(
line: i + 1,
severity: MarkdownValidationSeverity.warning,
message:
'Commentaar mist `_class:` of een bekende ocideck-sleutel.',
),
);
}
}
}
void _validateFenceBalance(
List<String> lines,
List<MarkdownValidationIssue> issues,
) {
var fenceCount = 0;
int? firstFenceLine;
for (var i = 0; i < lines.length; i++) {
if (RegExp(r'^\s*```').hasMatch(lines[i])) {
fenceCount++;
firstFenceLine ??= i + 1;
}
}
if (fenceCount.isOdd && firstFenceLine != null) {
issues.add(
MarkdownValidationIssue(
line: firstFenceLine,
severity: MarkdownValidationSeverity.error,
message: 'Codeblok is niet afgesloten met ```.',
),
);
}
}
void _validateSlideBlock({
required String block,
required int slideNumber,
required int startLine,
required List<MarkdownValidationIssue> issues,
}) {
final blockLines = block.split('\n');
int lineNo(int index) => startLine + index;
final classMatch = RegExp(
r'<!--\s*_class:\s*([^>]+?)\s*-->',
).firstMatch(block);
if (classMatch == null &&
RegExp(r'<!--\s*_class:').hasMatch(block) &&
classMatch == null) {
final badLine = blockLines.indexWhere(
(line) => line.contains('<!--') && line.contains('_class:'),
);
if (badLine >= 0) {
issues.add(
MarkdownValidationIssue(
line: lineNo(badLine),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: `_class`-commentaar is ongeldig. Gebruik `<!-- _class: … -->`.',
),
);
}
}
final cssClass = classMatch?.group(1)?.trim() ?? '';
final classTokens = cssClass
.split(RegExp(r'\s+'))
.where((token) => token.isNotEmpty)
.toList();
for (final token in classTokens) {
if (!_knownClassTokens.contains(token)) {
final classLine = blockLines.indexWhere(
(line) => line.contains('<!-- _class:'),
);
issues.add(
MarkdownValidationIssue(
line: lineNo(classLine >= 0 ? classLine : 0),
severity: MarkdownValidationSeverity.warning,
message:
'Slide $slideNumber: onbekende class "$token". Dit kan het slidetype beïnvloeden.',
),
);
}
}
for (var i = 0; i < blockLines.length; i++) {
final line = blockLines[i];
final trimmed = line.trim();
if (trimmed.startsWith('<!-- tlp:')) {
final value = trimmed.substring('<!-- tlp:'.length).replaceAll('-->', '').trim();
if (!_isValidTlpKey(value)) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: onbekend TLP-niveau "$value".',
),
);
}
}
if (trimmed.startsWith('<!-- advance:')) {
final value = trimmed
.substring('<!-- advance:'.length)
.replaceAll('-->', '')
.trim();
if (double.tryParse(value) == null) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: advance-waarde "$value" is geen getal.',
),
);
}
}
if (trimmed.startsWith('<!-- ocideck_list_style:')) {
final value = trimmed
.substring('<!-- ocideck_list_style:'.length)
.replaceAll('-->', '')
.trim();
if (!_validListStyles.contains(value)) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: onbekende lijststijl "$value". Gebruik bullets, numbered of checklist.',
),
);
}
}
for (final prefix in const [
'ocideck_two_bullets_left:',
'ocideck_two_bullets_right:',
'ocideck_two_bullets_left_title:',
'ocideck_two_bullets_right_title:',
]) {
if (trimmed.startsWith('<!-- $prefix')) {
final encoded = trimmed
.substring('<!-- $prefix'.length)
.replaceAll('-->', '')
.trim();
if (!_isValidEncodedPayload(prefix, encoded)) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: `$prefix`-commentaar bevat ongeldige base64/JSON.',
),
);
}
}
}
if (RegExp(r'!\[[^\]]*\]\([^)]*$').hasMatch(trimmed)) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: afbeeldings-markdown is niet afgesloten met `)`.',
),
);
}
if (trimmed.startsWith('<video') && !trimmed.endsWith('>')) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: `<video>`-tag is onvolledig of niet afgesloten.',
),
);
} else if (trimmed.startsWith('<video') &&
RegExp(r'src="([^"]+)"').firstMatch(trimmed) == null) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message: 'Slide $slideNumber: `<video>` mist een `src="…"`-attribuut.',
),
);
}
if (trimmed.startsWith('<audio') && !trimmed.endsWith('>')) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: `<audio>`-tag is onvolledig of niet afgesloten.',
),
);
}
}
if (classTokens.contains('code')) {
_validateCodeSlide(blockLines, slideNumber, lineNo, issues);
}
if (classTokens.contains('chart')) {
_validateChartSlide(blockLines, slideNumber, lineNo, issues);
}
if (classTokens.contains('split')) {
_validateSplitSlide(blockLines, slideNumber, lineNo, issues);
}
if (classTokens.contains('two-bullets')) {
_validateTwoBulletsSlide(blockLines, slideNumber, lineNo, issues);
}
if (classTokens.contains('table')) {
_validateTableSlide(blockLines, slideNumber, lineNo, issues);
}
_validateDivBalance(blockLines, slideNumber, lineNo, issues);
}
void _validateCodeSlide(
List<String> blockLines,
int slideNumber,
int Function(int) lineNo,
List<MarkdownValidationIssue> issues,
) {
final fences = blockLines
.where((line) => RegExp(r'^\s*```').hasMatch(line))
.toList();
if (fences.length < 2) {
final firstFence = blockLines.indexWhere(
(line) => RegExp(r'^\s*```').hasMatch(line),
);
issues.add(
MarkdownValidationIssue(
line: lineNo(firstFence >= 0 ? firstFence : 0),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: broncode-slide vereist een fenced ```-blok.',
),
);
}
}
void _validateChartSlide(
List<String> blockLines,
int slideNumber,
int Function(int) lineNo,
List<MarkdownValidationIssue> issues,
) {
final openingIndex = blockLines.indexWhere(
(line) => RegExp(r'^\s*```chart\s*$').hasMatch(line.trim()),
);
if (openingIndex < 0) {
issues.add(
MarkdownValidationIssue(
line: lineNo(0),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: grafiek-slide vereist een ```chart-blok.',
),
);
return;
}
final jsonLines = <String>[];
var inFence = false;
var closingIndex = -1;
for (var i = 0; i < blockLines.length; i++) {
final trimmed = blockLines[i].trim();
if (RegExp(r'^```chart\s*$').hasMatch(trimmed)) {
inFence = true;
continue;
}
if (inFence && RegExp(r'^```\s*$').hasMatch(trimmed)) {
closingIndex = i;
break;
}
if (inFence) jsonLines.add(blockLines[i]);
}
if (closingIndex < 0) {
issues.add(
MarkdownValidationIssue(
line: lineNo(openingIndex),
severity: MarkdownValidationSeverity.error,
message: 'Slide $slideNumber: ```chart-blok is niet afgesloten.',
),
);
return;
}
final raw = jsonLines.join('\n').trim();
if (raw.isEmpty) {
issues.add(
MarkdownValidationIssue(
line: lineNo(openingIndex + 1),
severity: MarkdownValidationSeverity.warning,
message: 'Slide $slideNumber: grafiek-specificatie is leeg.',
),
);
return;
}
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
issues.add(
MarkdownValidationIssue(
line: lineNo(openingIndex + 1),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: grafiek-JSON moet een object `{…}` zijn.',
),
);
}
} catch (_) {
issues.add(
MarkdownValidationIssue(
line: lineNo(openingIndex + 1),
severity: MarkdownValidationSeverity.error,
message: 'Slide $slideNumber: grafiek-JSON is ongeldig.',
),
);
}
}
void _validateSplitSlide(
List<String> blockLines,
int slideNumber,
int Function(int) lineNo,
List<MarkdownValidationIssue> issues,
) {
_requireDiv(
blockLines: blockLines,
className: 'split-text',
slideNumber: slideNumber,
lineNo: lineNo,
issues: issues,
);
_requireDiv(
blockLines: blockLines,
className: 'split-image',
slideNumber: slideNumber,
lineNo: lineNo,
issues: issues,
);
}
void _validateTwoBulletsSlide(
List<String> blockLines,
int slideNumber,
int Function(int) lineNo,
List<MarkdownValidationIssue> issues,
) {
_requireDiv(
blockLines: blockLines,
className: 'ocideck-two-bullets',
slideNumber: slideNumber,
lineNo: lineNo,
issues: issues,
);
}
void _requireDiv({
required List<String> blockLines,
required String className,
required int slideNumber,
required int Function(int) lineNo,
required List<MarkdownValidationIssue> issues,
}) {
final openIndex = blockLines.indexWhere(
(line) => line.contains('<div class="$className"'),
);
if (openIndex < 0) {
issues.add(
MarkdownValidationIssue(
line: lineNo(0),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: verwacht `<div class="$className">`.',
),
);
return;
}
var depth = 0;
var closed = false;
for (var i = openIndex; i < blockLines.length; i++) {
final line = blockLines[i];
if (line.contains('<div')) depth++;
if (line.contains('</div>')) {
depth--;
if (depth == 0) {
closed = true;
break;
}
}
}
if (!closed) {
issues.add(
MarkdownValidationIssue(
line: lineNo(openIndex),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: `<div class="$className">` is niet afgesloten.',
),
);
}
}
void _validateTableSlide(
List<String> blockLines,
int slideNumber,
int Function(int) lineNo,
List<MarkdownValidationIssue> issues,
) {
final tableLineIndexes = <int>[];
for (var i = 0; i < blockLines.length; i++) {
if (blockLines[i].trim().startsWith('|')) {
tableLineIndexes.add(i);
}
}
if (tableLineIndexes.isEmpty) {
issues.add(
MarkdownValidationIssue(
line: lineNo(0),
severity: MarkdownValidationSeverity.warning,
message: 'Slide $slideNumber: tabel-slide bevat geen tabel.',
),
);
return;
}
if (tableLineIndexes.length == 1) {
issues.add(
MarkdownValidationIssue(
line: lineNo(tableLineIndexes.first),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: tabel mist een scheidingsrij (`| --- |`).',
),
);
return;
}
final separatorIndex = tableLineIndexes[1];
final cells = blockLines[separatorIndex]
.trim()
.replaceFirst(RegExp(r'^\|'), '')
.replaceFirst(RegExp(r'\|$'), '')
.split('|')
.map((cell) => cell.trim())
.toList();
if (!cells.every((cell) => RegExp(r'^:?-+:?$').hasMatch(cell))) {
issues.add(
MarkdownValidationIssue(
line: lineNo(separatorIndex),
severity: MarkdownValidationSeverity.error,
message:
'Slide $slideNumber: tweede tabelrij moet een scheidingsrij zijn (`| --- |`).',
),
);
}
}
void _validateDivBalance(
List<String> blockLines,
int slideNumber,
int Function(int) lineNo,
List<MarkdownValidationIssue> issues,
) {
var depth = 0;
int? firstOpenLine;
for (var i = 0; i < blockLines.length; i++) {
final line = blockLines[i];
final opens = RegExp(r'<div\b').allMatches(line).length;
final closes = RegExp(r'</div>').allMatches(line).length;
if (opens > 0 && firstOpenLine == null) firstOpenLine = i;
depth += opens - closes;
if (depth < 0) {
issues.add(
MarkdownValidationIssue(
line: lineNo(i),
severity: MarkdownValidationSeverity.error,
message: 'Slide $slideNumber: overtollige `</div>`.',
),
);
depth = 0;
}
}
if (depth > 0 && firstOpenLine != null) {
issues.add(
MarkdownValidationIssue(
line: lineNo(firstOpenLine),
severity: MarkdownValidationSeverity.error,
message: 'Slide $slideNumber: niet alle `<div>`-tags zijn afgesloten.',
),
);
}
}
bool _isValidTlpKey(String raw) {
final normalized = raw.trim().toLowerCase();
if (normalized.isEmpty || normalized == 'none') return true;
return TlpLevel.values.any((level) => level.key == normalized);
}
bool _isValidEncodedPayload(String prefix, String encoded) {
if (encoded.isEmpty) return prefix.contains('_title');
try {
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
if (prefix.contains('_title')) return true;
final raw = jsonDecode(decoded);
return raw is List;
} catch (_) {
return false;
}
}
}

View file

@ -0,0 +1,490 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/markdown_validation.dart';
import '../../services/markdown_validator.dart';
class MarkdownDeckEditor extends StatefulWidget {
final String initialContent;
final bool Function(String) onApply;
final bool parseError;
final VoidCallback onExitMarkdown;
const MarkdownDeckEditor({
super.key,
required this.initialContent,
required this.onApply,
required this.parseError,
required this.onExitMarkdown,
});
@override
State<MarkdownDeckEditor> createState() => _MarkdownDeckEditorState();
}
class _MarkdownDeckEditorState extends State<MarkdownDeckEditor> {
static const _lineHeight = 19.5;
late final TextEditingController _ctrl;
late final ScrollController _scrollController;
final _validator = MarkdownValidator();
MarkdownValidationResult? _validation;
bool _showIssues = false;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.initialContent);
_scrollController = ScrollController();
}
@override
void dispose() {
_ctrl.dispose();
_scrollController.dispose();
super.dispose();
}
MarkdownValidationResult _runValidation() {
final result = _validator.validate(_ctrl.text);
setState(() {
_validation = result;
_showIssues = result.hasIssues;
});
return result;
}
void _jumpToLine(int line) {
final lines = _ctrl.text.split('\n');
final index = (line - 1).clamp(0, lines.length - 1);
var offset = 0;
for (var i = 0; i < index; i++) {
offset += lines[i].length + 1;
}
_ctrl.selection = TextSelection(
baseOffset: offset,
extentOffset: offset + lines[index].length,
);
final target = (line - 1) * _lineHeight;
if (_scrollController.hasClients) {
_scrollController.animateTo(
target.clamp(0.0, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
Future<bool> _confirmApplyWithIssues(
BuildContext context,
MarkdownValidationResult result,
) async {
final choice = await showDialog<_ApplyChoiceResult>(
context: context,
builder: (ctx) {
final l10n = ctx.l10n;
return AlertDialog(
title: Text(l10n.d('Syntaxproblemen gevonden')),
content: SizedBox(
width: 520,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${l10n.d('De markdown bevat')} ${result.errorCount} '
'${l10n.d('fout(en) en')} ${result.warningCount} '
'${l10n.d('waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.')}',
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 260),
child: SingleChildScrollView(
child: Column(
children: [
for (final issue in result.issues)
_IssueTile(
issue: issue,
onTap: () => Navigator.pop(
ctx,
_ApplyChoiceResult(_ApplyChoice.edit, issue.line),
),
),
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, const _ApplyChoiceResult(_ApplyChoice.edit)),
child: Text(l10n.d('Terug naar editor')),
),
TextButton(
onPressed: () =>
Navigator.pop(ctx, const _ApplyChoiceResult(_ApplyChoice.cancel)),
child: Text(l10n.t('cancel')),
),
ElevatedButton(
onPressed: () => Navigator.pop(
ctx,
const _ApplyChoiceResult(_ApplyChoice.applyAnyway),
),
child: Text(l10n.d('Toch toepassen')),
),
],
);
},
);
if (choice?.choice == _ApplyChoice.edit && choice?.focusLine != null) {
_jumpToLine(choice!.focusLine!);
}
return choice?.choice == _ApplyChoice.applyAnyway;
}
Future<void> _applyMarkdown() async {
final result = _runValidation();
if (result.hasIssues) {
final proceed = await _confirmApplyWithIssues(context, result);
if (!proceed) return;
}
final ok = widget.onApply(_ctrl.text);
if (ok) widget.onExitMarkdown();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final lineCount = '\n'.allMatches(_ctrl.text).length + 1;
final issueLines = <int, MarkdownValidationSeverity>{
for (final issue in _validation?.issues ?? const [])
issue.line: issue.severity,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: const Color(0xFFFFF9E6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
const Icon(Icons.code, size: 14, color: Color(0xFF92400E)),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.d(
'Markdown modus — bewerk de volledige presentatie als Marp Markdown',
),
style: const TextStyle(
fontSize: 11,
color: Color(0xFF92400E),
),
),
),
TextButton.icon(
onPressed: _runValidation,
icon: const Icon(Icons.rule, size: 16),
label: Text(l10n.d('Controleren')),
),
TextButton(
onPressed: _applyMarkdown,
child: Text(l10n.d('Toepassen')),
),
TextButton(
onPressed: widget.onExitMarkdown,
child: Text(l10n.t('cancel')),
),
],
),
),
if (_validation != null)
_ValidationSummaryBar(
result: _validation!,
expanded: _showIssues,
onToggle: () => setState(() => _showIssues = !_showIssues),
onJumpToLine: _jumpToLine,
),
if (widget.parseError)
Container(
color: const Color(0xFFFEE2E2),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
const Icon(
Icons.warning_amber_outlined,
size: 14,
color: Colors.red,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.d(
'Markdown kon niet worden verwerkt. Controleer de syntax.',
),
style: const TextStyle(fontSize: 11, color: Colors.red),
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_LineNumberGutter(
scrollController: _scrollController,
lineCount: lineCount,
issueLines: issueLines,
onLineTap: _jumpToLine,
),
Expanded(
child: TextField(
controller: _ctrl,
scrollController: _scrollController,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
height: 1.5,
),
decoration: const InputDecoration(
contentPadding: EdgeInsets.fromLTRB(8, 16, 16, 16),
border: InputBorder.none,
filled: true,
fillColor: Color(0xFFF8FAFC),
),
onChanged: (_) {
setState(() {
_validation = null;
});
},
),
),
],
),
),
],
);
}
}
enum _ApplyChoice { edit, cancel, applyAnyway }
class _ApplyChoiceResult {
final _ApplyChoice choice;
final int? focusLine;
const _ApplyChoiceResult(this.choice, [this.focusLine]);
}
class _ValidationSummaryBar extends StatelessWidget {
final MarkdownValidationResult result;
final bool expanded;
final VoidCallback onToggle;
final ValueChanged<int> onJumpToLine;
const _ValidationSummaryBar({
required this.result,
required this.expanded,
required this.onToggle,
required this.onJumpToLine,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final color = result.isValid
? const Color(0xFFFEF3C7)
: const Color(0xFFFEE2E2);
final iconColor = result.isValid
? const Color(0xFF92400E)
: Colors.red.shade700;
final summary = result.hasIssues
? '${result.errorCount} ${l10n.d('fout(en),')} '
'${result.warningCount} ${l10n.d('waarschuwing(en)')}'
: l10n.d('Geen syntaxproblemen gevonden');
return Material(
color: color,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
onTap: result.hasIssues ? onToggle : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Icon(
result.hasIssues
? Icons.warning_amber_outlined
: Icons.check_circle_outline,
size: 14,
color: iconColor,
),
const SizedBox(width: 6),
Expanded(
child: Text(
summary,
style: TextStyle(fontSize: 11, color: iconColor),
),
),
if (result.hasIssues)
Icon(
expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: iconColor,
),
],
),
),
),
if (expanded && result.hasIssues)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 160),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
itemCount: result.issues.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final issue = result.issues[index];
return _IssueTile(
issue: issue,
onTap: () => onJumpToLine(issue.line),
);
},
),
),
],
),
);
}
}
class _IssueTile extends StatelessWidget {
final MarkdownValidationIssue issue;
final VoidCallback onTap;
const _IssueTile({required this.issue, required this.onTap});
@override
Widget build(BuildContext context) {
final isError = issue.severity == MarkdownValidationSeverity.error;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isError ? Icons.error_outline : Icons.info_outline,
size: 14,
color: isError ? Colors.red.shade700 : const Color(0xFF92400E),
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Regel ${issue.line}: ${issue.message}',
style: TextStyle(
fontSize: 11,
color: isError ? Colors.red.shade700 : const Color(0xFF92400E),
),
),
),
],
),
),
);
}
}
class _LineNumberGutter extends StatelessWidget {
final ScrollController scrollController;
final int lineCount;
final Map<int, MarkdownValidationSeverity> issueLines;
final ValueChanged<int> onLineTap;
const _LineNumberGutter({
required this.scrollController,
required this.lineCount,
required this.issueLines,
required this.onLineTap,
});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFFEEF2F7),
child: SizedBox(
width: 44,
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.only(top: 16),
physics: const NeverScrollableScrollPhysics(),
itemCount: lineCount,
itemBuilder: (context, index) {
final line = index + 1;
return _LineNumberCell(
line: line,
severity: issueLines[line],
onTap: onLineTap,
);
},
),
),
);
}
}
class _LineNumberCell extends StatelessWidget {
final int line;
final MarkdownValidationSeverity? severity;
final ValueChanged<int> onTap;
const _LineNumberCell({
required this.line,
required this.severity,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final bg = switch (severity) {
MarkdownValidationSeverity.error => const Color(0xFFFECACA),
MarkdownValidationSeverity.warning => const Color(0xFFFDE68A),
null => Colors.transparent,
};
return GestureDetector(
onTap: () => onTap(line),
child: Container(
height: _MarkdownDeckEditorState._lineHeight,
color: bg,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 8),
child: Text(
'$line',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11,
height: 1.5,
color: severity == MarkdownValidationSeverity.error
? Colors.red.shade700
: severity == MarkdownValidationSeverity.warning
? const Color(0xFF92400E)
: const Color(0xFF94A3B8),
),
),
),
);
}
}

View file

@ -23,6 +23,7 @@ import '../editors/title_editor.dart';
import '../editors/two_bullets_editor.dart';
import '../editors/two_images_editor.dart';
import '../editors/video_slide_editor.dart';
import '../editors/markdown_deck_editor.dart';
class EditorPanel extends ConsumerWidget {
const EditorPanel({super.key});
@ -52,7 +53,7 @@ class EditorPanel extends ConsumerWidget {
];
if (editor.mode == EditorMode.markdown) {
return _MarkdownModeEditor(
return MarkdownDeckEditor(
// Verse instantie na undo/redo zodat de markdown opnieuw wordt geladen.
key: ValueKey('md-${deckState.revision}'),
initialContent: deckNotifier.generateMarkdown(),
@ -810,126 +811,3 @@ class _NotesFieldState extends State<_NotesField> {
);
}
}
// Markdown mode editor
class _MarkdownModeEditor extends StatefulWidget {
final String initialContent;
final bool Function(String) onApply;
final bool parseError;
final VoidCallback onExitMarkdown;
const _MarkdownModeEditor({
super.key,
required this.initialContent,
required this.onApply,
required this.parseError,
required this.onExitMarkdown,
});
@override
State<_MarkdownModeEditor> createState() => _MarkdownModeEditorState();
}
class _MarkdownModeEditorState extends State<_MarkdownModeEditor> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.initialContent);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Toolbar
Container(
color: const Color(0xFFFFF9E6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
const Icon(Icons.code, size: 14, color: Color(0xFF92400E)),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.d(
'Markdown modus — bewerk de volledige presentatie als Marp Markdown',
),
style: const TextStyle(
fontSize: 11,
color: Color(0xFF92400E),
),
),
),
TextButton(
onPressed: () {
final ok = widget.onApply(_ctrl.text);
if (ok) widget.onExitMarkdown();
},
child: Text(l10n.d('Toepassen')),
),
TextButton(
onPressed: widget.onExitMarkdown,
child: Text(l10n.t('cancel')),
),
],
),
),
if (widget.parseError)
Container(
color: const Color(0xFFFEE2E2),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
const Icon(
Icons.warning_amber_outlined,
size: 14,
color: Colors.red,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.d(
'Markdown kon niet worden verwerkt. Controleer de syntax.',
),
style: const TextStyle(fontSize: 11, color: Colors.red),
),
),
],
),
),
const Divider(height: 1),
// Code editor
Expanded(
child: TextField(
controller: _ctrl,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
height: 1.5,
),
decoration: const InputDecoration(
contentPadding: EdgeInsets.all(16),
border: InputBorder.none,
filled: true,
fillColor: Color(0xFFF8FAFC),
),
),
),
],
);
}
}

View file

@ -6,7 +6,6 @@ import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/markdown_service.dart';
import '../../utils/log.dart';
import '../../utils/url_launcher_util.dart';
import '../slides/slide_preview.dart';
import 'annotation_overlay.dart';
@ -130,16 +129,6 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
bullets2: List<String>.from(m['bullets2'] as List? ?? const []),
);
});
case 'close':
try {
final self = await WindowController.fromCurrentEngine();
await self.close();
} catch (e) {
logWarning(
'_AudienceWindowAppState._onPresenterCall: close window',
e,
);
}
}
return null;
}

View file

@ -23,6 +23,32 @@ import 'annotation_overlay.dart';
import 'audience_window.dart';
import 'rehearsal_summary.dart';
/// Guards teardown of the secondary audience window so native close is only
/// invoked once (double-close on Linux can crash the embedder).
@visibleForTesting
class AudienceWindowHandle {
AudienceWindowHandle(
this.controller, {
Future<void> Function(WindowController controller)? closeImpl,
}) : _closeImpl = closeImpl ?? ((c) => c.close());
final WindowController controller;
final Future<void> Function(WindowController controller) _closeImpl;
bool _closed = false;
bool get isClosed => _closed;
Future<void> close() async {
if (_closed) return;
_closed = true;
try {
await _closeImpl(controller);
} catch (e) {
logWarning('AudienceWindowHandle.close: audience window', e);
}
}
}
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
enum _Blank { none, black, white }
@ -38,9 +64,9 @@ class FullscreenPresenter extends StatefulWidget {
final Duration? targetDuration;
/// When set, this presenter drives a separate audience (beamer) window: the
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
/// laptop shows the presenter view, the slide goes to [audience]. Null
/// for the classic single-screen mode.
final WindowController? audienceWindow;
final AudienceWindowHandle? audience;
/// Annotation layer keyed by [Slide.id], and a callback to persist changes
/// made while presenting back to the deck.
@ -56,7 +82,7 @@ class FullscreenPresenter extends StatefulWidget {
required this.initialIndex,
this.tlp = TlpLevel.none,
this.targetDuration,
this.audienceWindow,
this.audience,
this.initialAnnotations = const {},
this.onAnnotationsChanged,
this.onSlideChanged,
@ -213,20 +239,27 @@ class FullscreenPresenter extends StatefulWidget {
});
WindowController? audience;
AudienceWindowHandle? audienceHandle;
try {
audience = await WindowController.create(
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
);
audienceHandle = AudienceWindowHandle(audience);
await audience.show();
await audience.coverScreen(external: true);
} catch (e) {
logError(
'FullscreenPresenter.showDualScreen: audience window setup failed',
e,
);
if (audienceHandle != null) {
await audienceHandle.close();
}
audience = null;
audienceHandle = null;
}
if (audience == null) {
if (audience == null || audienceHandle == null) {
if (context.mounted) {
await show(
context,
@ -257,7 +290,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
audienceWindow: audience,
audience: audienceHandle,
initialAnnotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
onSlideChanged: onSlideChanged,
@ -271,7 +304,7 @@ class FullscreenPresenter extends StatefulWidget {
} finally {
await _restoreWakeLock(hadWakeLock);
// Make sure the audience window is gone even if exit didn't close it.
audience.close().catchError((_) => null);
await audienceHandle.close();
}
}
@ -389,7 +422,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
int _displayIndex = 0;
/// True when this presenter drives a separate audience (beamer) window.
bool get _dual => widget.audienceWindow != null;
bool get _dual => widget.audience != null;
/// Last (index, blank) pushed to the audience window, to avoid redundant sends.
int? _lastSentIndex;
@ -484,7 +517,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
/// Mirror the current index/blank state to the audience window when it changed.
void _syncAudience() {
final aw = widget.audienceWindow;
final aw = widget.audience?.controller;
if (aw == null) return;
final blank = _blankCode;
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
@ -534,7 +567,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
/// Send the current slide's strokes to the beamer (keyed by index there).
void _pushInk() {
if (widget.audienceWindow == null) return;
if (widget.audience == null) return;
audienceChannel
.invokeMethod('ink', {
'index': _index,
@ -557,7 +590,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
}
void _onLaserMove(Offset? point) {
if (widget.audienceWindow == null) return;
if (widget.audience == null) return;
final now = DateTime.now();
// Throttle to keep the channel calm; always send the "gone" (null) event.
if (point != null &&
@ -578,7 +611,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
/// pen lifts. The committed stroke still follows over the 'ink' channel; this
/// just keeps the in-progress preview in sync for the same slide.
void _onActiveStroke(InkStroke? stroke) {
if (widget.audienceWindow == null) return;
if (widget.audience == null) return;
final now = DateTime.now();
// Throttle growth events; always send the "done" (null) event so the
// beamer drops its live preview the moment the stroke commits.
@ -777,12 +810,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
Future<void> _exit() async {
_advanceTimer?.cancel();
await _maybeShowRehearsalSummary();
final aw = widget.audienceWindow;
final aw = widget.audience;
if (aw != null) {
// Dual mode: the main window was never put in full screen; just tear down
// the audience window.
audienceChannel.invokeMethod('close').catchError((_) => null);
aw.close().catchError((_) => null);
// the audience window (once double-close crashes the Linux embedder).
await aw.close();
} else {
await windowManager.setFullScreen(false);
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
@ -54,6 +55,22 @@ void main() {
);
});
test('AudienceWindowHandle closes only once', () async {
var closeCount = 0;
final handle = AudienceWindowHandle(
WindowController.fromWindowId('test'),
closeImpl: (_) async {
closeCount++;
},
);
await handle.close();
await handle.close();
expect(closeCount, 1);
expect(handle.isClosed, isTrue);
});
test('dual-screen mode requires a desktop platform and two displays', () {
expect(
shouldUseDualScreen(

View file

@ -0,0 +1,224 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/services/markdown_service.dart';
import 'package:ocideck/services/markdown_validator.dart';
void main() {
final service = MarkdownService();
final validator = MarkdownValidator();
test('valid generated deck has no errors', () {
final markdown = service.generateDeck(
Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.bullets).copyWith(
title: 'Kop',
bullets: ['Eerste punt'],
),
],
),
);
final result = validator.validate(markdown);
expect(result.isValid, isTrue);
expect(result.hasIssues, isFalse);
});
test('detects unclosed front matter', () {
const markdown = '---\nmarp: true\ntheme: ocideck\n# Titel\n';
final result = validator.validate(markdown);
expect(result.isValid, isFalse);
expect(
result.issues.any(
(issue) => issue.message.contains('Front matter is niet afgesloten'),
),
isTrue,
);
});
test('detects unclosed code fence', () {
const markdown = '''
---
marp: true
---
# Code
```dart
void main() {}
''';
final result = validator.validate(markdown);
expect(result.isValid, isFalse);
expect(
result.issues.any(
(issue) => issue.message.contains('Codeblok is niet afgesloten'),
),
isTrue,
);
});
test('detects code slide without fenced block', () {
const markdown = '''
---
marp: true
---
<!-- _class: code -->
# Titel
print('hi');
''';
final result = validator.validate(markdown);
expect(result.isValid, isFalse);
expect(
result.issues.any(
(issue) => issue.message.contains('broncode-slide vereist'),
),
isTrue,
);
});
test('detects invalid chart JSON', () {
const markdown = '''
---
marp: true
---
<!-- _class: chart -->
```chart
{ broken json
```
''';
final result = validator.validate(markdown);
expect(result.isValid, isFalse);
expect(
result.issues.any(
(issue) => issue.message.contains('grafiek-JSON is ongeldig'),
),
isTrue,
);
});
test('detects split slide with missing divs', () {
const markdown = '''
---
marp: true
---
<!-- _class: split -->
<div class="split-text">
# Kop
</div>
''';
final result = validator.validate(markdown);
expect(result.isValid, isFalse);
expect(
result.issues.any(
(issue) => issue.message.contains('split-image'),
),
isTrue,
);
});
test('detects invalid two-bullets encoded comment', () {
const markdown = '''
---
marp: true
---
<!-- _class: two-bullets -->
<!-- ocideck_two_bullets_left: !!! -->
<div class="ocideck-two-bullets">
<div></div>
<div></div>
</div>
''';
final result = validator.validate(markdown);
expect(result.isValid, isFalse);
expect(
result.issues.any(
(issue) => issue.message.contains('ocideck_two_bullets_left:'),
),
isTrue,
);
});
test('detects table without separator row', () {
const markdown = '''
---
marp: true
---
<!-- _class: table -->
| Kop 1 | Kop 2 |
| a | b |
''';
final result = validator.validate(markdown);
expect(result.isValid, isFalse);
expect(
result.issues.any(
(issue) => issue.message.contains('scheidingsrij'),
),
isTrue,
);
});
test('detects malformed image markdown', () {
const markdown = '''
---
marp: true
---
![bg 50%](images/foto.png
''';
final result = validator.validate(markdown);
expect(result.isValid, isFalse);
expect(
result.issues.any(
(issue) => issue.message.contains('afbeeldings-markdown'),
),
isTrue,
);
});
test('accepts valid encoded two-bullets payload', () {
final encoded = base64Url.encode(utf8.encode(jsonEncode(['A', 'B'])));
final markdown = '''
---
marp: true
---
<!-- _class: two-bullets -->
<!-- ocideck_two_bullets_left: $encoded -->
<div class="ocideck-two-bullets">
<div></div>
<div></div>
</div>
''';
final result = validator.validate(markdown);
expect(
result.issues.where(
(issue) => issue.message.contains('ocideck_two_bullets_left:'),
),
isEmpty,
);
});
}

View file

@ -165,11 +165,8 @@ void MultiWindowManager::ObserveWindowClose(const std::string& window_id,
G_CALLBACK(+[](GtkWidget* widget, gpointer arg) {
auto* window_id_ptr = static_cast<std::string*>(arg);
GtkWidget* child = gtk_bin_get_child(GTK_BIN(widget));
if (child && FL_IS_VIEW(child)) {
gtk_container_remove(GTK_CONTAINER(widget), child);
}
// Let GTK tear down the FlView with the window; manually removing the
// view first triggers FlutterEngineRemoveView errors on Linux.
MultiWindowManager::Instance()->RemoveWindow(*window_id_ptr);
delete window_id_ptr;
}),