Merge pull request 'feature/markdown-syntax-check-linux-presenter' (#8) from feature/markdown-syntax-check-linux-presenter into main
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
Reviewed-on: #8
This commit is contained in:
commit
6d90e7d7b4
14 changed files with 1671 additions and 157 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | ``. |
|
||||
| **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).
|
||||
|
|
|
|||
|
|
@ -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 | ``. |
|
||||
| **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,
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
36
lib/models/markdown_validation.dart
Normal file
36
lib/models/markdown_validation.dart
Normal 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;
|
||||
}
|
||||
704
lib/services/markdown_validator.dart
Normal file
704
lib/services/markdown_validator.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
500
lib/widgets/editors/markdown_deck_editor.dart
Normal file
500
lib/widgets/editors/markdown_deck_editor.dart
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
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: ClipRect(
|
||||
child: AnimatedBuilder(
|
||||
animation: scrollController,
|
||||
builder: (context, child) {
|
||||
final offset =
|
||||
scrollController.hasClients ? scrollController.offset : 0.0;
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 16 - offset),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < lineCount; index++)
|
||||
_LineNumberCell(
|
||||
line: index + 1,
|
||||
severity: issueLines[index + 1],
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
224
test/markdown_validator_test.dart
Normal file
224
test/markdown_validator_test.dart
Normal 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
|
||||
---
|
||||
|
||||
;
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue