diff --git a/NOTICE b/NOTICE
index b120235..9ea879c 100644
--- a/NOTICE
+++ b/NOTICE
@@ -9,3 +9,7 @@ Source repository: https://github.com/busystack/busymark
The BusyMark name and logo are identifying marks of BusyStack.
This license does not grant permission to use the BusyMark name or logo to
imply endorsement, sponsorship, or official status for modified versions.
+
+BusyMark includes the Typst compiler (https://typst.app/), distributed under
+the Apache License 2.0. Its license and upstream notices are installed with the
+application under share/licenses/typst.
diff --git a/README.md b/README.md
index 145dd46..233a414 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ projects.
- Create Writerside Markdown and XML topics from the TOC.
- Edit and save local files.
- Preview Markdown content.
+- Export Markdown documents as accessible, tagged PDF files.
- Navigate project files, table of contents, and document outline.
- Run basic diagnostics.
- Reopen recent workspaces.
@@ -83,6 +84,19 @@ Large folders are scanned defensively. Generated and vendor directories such as
`.git`, `build`, `dist`, `node_modules`, `.dart_tool`, `.gradle`, and `target`
are skipped to keep the app responsive.
+## PDF export
+
+Use **Main menu → Export as PDF** or Ctrl+Shift+E
+while a Markdown document is active. BusyMark exports the current editor
+contents, including unsaved changes, and offers A4 or Letter paper, portrait or
+landscape orientation, three margin sizes, and optional page numbers. Writerside
+topics are not exported yet.
+
+PDF generation is local and offline. BusyMark bundles the pinned Typst compiler;
+users do not install or configure a separate program. Local PNG, JPEG, GIF, and
+safe SVG images are included. Remote images are deliberately not downloaded
+during export and are represented by their alternative text.
+
## Run From Source
1. [Install Flutter](https://docs.flutter.dev/install)
@@ -196,14 +210,24 @@ Store listing translations are managed outside `snap/snapcraft.yaml`.
## Build Linux Locally
-Source builds require the libhandy development headers. Packaged users receive
-the runtime library with BusyMark and do not install development packages.
+Source builds require the libhandy development headers, `curl`, and `xz-utils`.
+Packaged users receive every runtime component with BusyMark and do not install
+development packages.
```bash
-sudo apt-get install libhandy-1-dev
+sudo apt-get install curl libhandy-1-dev xz-utils
flutter build linux
```
+The Linux build downloads the matching x86_64 or ARM64 Typst 0.15.1 binary from
+its official release and verifies its pinned SHA-256 checksum before bundling
+it. For an offline build, point the build at the matching official archive:
+
+```bash
+BUSYMARK_TYPST_ARCHIVE=/path/to/typst-x86_64-unknown-linux-musl.tar.xz \
+ flutter build linux
+```
+
The Linux desktop file uses the application id `io.busystack.busymark` and
installs the app icon from `assets/branding/busymark_logo.svg`.
@@ -215,4 +239,5 @@ handling, and clear user-facing behavior.
## License
-Apache-2.0. See [LICENSE](LICENSE).
+Apache-2.0. See [LICENSE](LICENSE). The bundled Typst compiler's license and
+upstream notices are installed under `share/licenses/typst`.
diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ
new file mode 100644
index 0000000..995e243
--- /dev/null
+++ b/assets/export/markdown.typ
@@ -0,0 +1,241 @@
+// BusyMark Markdown PDF renderer. All document data enters through JSON;
+// Markdown text is never evaluated as Typst source.
+#let data = json("document.json")
+#let metadata = data.metadata
+#let options = data.options
+
+#set document(
+ title: metadata.title,
+ author: if metadata.author == "" { () } else { (metadata.author,) },
+ description: metadata.description,
+ keywords: metadata.keywords,
+)
+#set page(
+ paper: options.paper,
+ flipped: options.landscape,
+ margin: (
+ x: options.marginHorizontalPt * 1pt,
+ y: options.marginVerticalPt * 1pt,
+ ),
+ numbering: if options.pageNumbers { "1" } else { none },
+ number-align: center + bottom,
+)
+#set text(
+ fallback: true,
+ size: 10.5pt,
+ lang: metadata.language,
+)
+#set par(leading: 0.68em)
+#set heading(numbering: none)
+#show heading.where(level: 1): set text(size: 22pt, weight: "bold")
+#show heading.where(level: 2): set text(size: 17pt, weight: "bold")
+#show heading.where(level: 3): set text(size: 13.5pt, weight: "bold")
+#show heading.where(level: 4): set text(size: 11.5pt, weight: "bold")
+#show link: set text(fill: rgb("2563a5"))
+
+#let value-or(item, key, default) = item.at(key, default: default)
+
+#let render-inlines(items) = {
+ for item in items {
+ let kind = item.kind
+ let children = value-or(item, "children", ())
+ let body = if children.len() > 0 {
+ render-inlines(children)
+ } else {
+ text(value-or(item, "text", ""))
+ }
+
+ if kind == "text" {
+ body
+ } else if kind == "strong" {
+ strong(body)
+ } else if kind == "emphasis" {
+ emph(body)
+ } else if kind == "underline" {
+ underline(body)
+ } else if kind == "strikethrough" {
+ strike(body)
+ } else if kind == "code" {
+ raw(value-or(item, "text", ""))
+ } else if kind == "link" {
+ let destination = value-or(item, "destination", "")
+ if destination == "" {
+ body
+ } else if destination.starts-with("#") {
+ link(label(destination.slice(1)), body)
+ } else {
+ link(destination, body)
+ }
+ } else if kind == "image" {
+ let asset = value-or(item, "asset", "")
+ let alt = value-or(item, "alt", "")
+ if asset == "" {
+ emph(text(if alt == "" { "[Image unavailable]" } else { "[Image: " + alt + "]" }))
+ } else {
+ image(asset, width: 1.25em, height: 1.25em, fit: "contain", alt: alt)
+ }
+ } else if kind == "softBreak" {
+ text(" ")
+ } else if kind == "hardBreak" {
+ linebreak()
+ } else {
+ body
+ }
+ }
+}
+
+#let render-display-image(item) = {
+ let asset = value-or(item, "asset", "")
+ let alt = value-or(item, "alt", "")
+ if asset == "" {
+ emph(text(if alt == "" { "[Image unavailable]" } else { "[Image: " + alt + "]" }))
+ } else {
+ layout(size => image(
+ asset,
+ width: 100%,
+ height: 72% * size.height,
+ fit: "contain",
+ alt: alt,
+ ))
+ }
+}
+
+#let render-table(block-data) = {
+ let rows = value-or(block-data, "children", ())
+ if rows.len() == 0 {
+ none
+ } else {
+ let column-count = calc.max(..rows.map(row => value-or(row, "children", ()).len()))
+ let cells = ()
+ for row in rows {
+ let is-header = value-or(row, "header", false)
+ let row-cells = ()
+ for cell in value-or(row, "children", ()) {
+ let cell-body = render-inlines(value-or(cell, "inlines", ()))
+ let alignment = value-or(cell, "align", "left")
+ let cell-align = if alignment == "center" {
+ center
+ } else if alignment == "right" {
+ right
+ } else {
+ left
+ }
+ row-cells.push(table.cell(
+ align: cell-align,
+ if is-header { strong(cell-body) } else { cell-body },
+ ))
+ }
+ for ignored in range(value-or(row, "children", ()).len(), column-count) {
+ row-cells.push(none)
+ }
+ if is-header {
+ cells.push(table.header(..row-cells))
+ } else {
+ for cell in row-cells { cells.push(cell) }
+ }
+ }
+ table(
+ columns: column-count,
+ inset: 5pt,
+ stroke: 0.5pt + rgb("c8cdd4"),
+ fill: (x, y) => if y == 0 { rgb("f1f3f5") } else { none },
+ ..cells,
+ )
+ }
+}
+
+#let render-list(block-data, render) = {
+ let items = value-or(block-data, "children", ()).map(item => {
+ let task = value-or(item, "task", none)
+ if task != none {
+ box(width: 1.35em, text(if task { "☑" } else { "☐" }))
+ }
+ render-inlines(value-or(item, "inlines", ()))
+ let nested = value-or(item, "children", ())
+ if nested.len() > 0 {
+ for nested-block in nested { render(nested-block) }
+ }
+ })
+ if value-or(block-data, "ordered", false) {
+ enum(start: value-or(block-data, "start", 1), ..items)
+ } else {
+ list(..items)
+ }
+}
+
+#let render-block(block-data) = {
+ let kind = block-data.kind
+ let inlines = value-or(block-data, "inlines", ())
+ let children = value-or(block-data, "children", ())
+ if kind == "heading" {
+ let element = heading(
+ level: value-or(block-data, "level", 1),
+ outlined: true,
+ render-inlines(inlines),
+ )
+ let anchor = value-or(block-data, "id", "")
+ if anchor == "" { element } else { [#element #label(anchor)] }
+ } else if kind == "paragraph" {
+ par(render-inlines(inlines))
+ } else if kind == "code" {
+ let language = value-or(block-data, "language", "")
+ block(
+ width: 100%,
+ fill: rgb("f4f5f7"),
+ inset: 8pt,
+ radius: 3pt,
+ if language == "" {
+ raw(value-or(block-data, "text", ""), block: true)
+ } else {
+ raw(value-or(block-data, "text", ""), block: true, lang: language)
+ },
+ )
+ } else if kind == "list" {
+ render-list(block-data, render-block)
+ } else if kind == "blockquote" {
+ quote(
+ block: true,
+ if children.len() > 0 {
+ for child in children { render-block(child) }
+ } else {
+ render-inlines(inlines)
+ },
+ )
+ } else if kind == "thematicBreak" {
+ block(above: 0.8em, below: 0.8em, line(length: 100%, stroke: 0.6pt + rgb("aeb4bc")))
+ } else if kind == "image" {
+ let image-body = {
+ for item in inlines {
+ if item.kind == "image" {
+ render-display-image(item)
+ } else {
+ render-inlines((item,))
+ }
+ }
+ }
+ let destination = value-or(block-data, "destination", "")
+ align(center, if destination == "" {
+ image-body
+ } else if destination.starts-with("#") {
+ link(label(destination.slice(1)), image-body)
+ } else {
+ link(destination, image-body)
+ })
+ } else if kind == "table" {
+ render-table(block-data)
+ } else if kind == "rawText" {
+ block(
+ width: 100%,
+ fill: rgb("f7f7f8"),
+ inset: 7pt,
+ radius: 3pt,
+ raw(value-or(block-data, "text", ""), block: true),
+ )
+ } else if kind == "group" {
+ for child in children { render-block(child) }
+ } else {
+ render-inlines(inlines)
+ }
+}
+
+#for block-data in data.blocks { render-block(block-data) }
diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb
index 1313b5e..13a0b95 100644
--- a/lib/l10n/app_ar.arb
+++ b/lib/l10n/app_ar.arb
@@ -2381,6 +2381,29 @@
"errorWritersideRedirectInvalid": "لم يعد هدف إعادة التوجيه المحدد صالحًا. حدده مرة أخرى.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "تعذّر التراجع بالكامل عن إزالة الموضوع. راجع هذه المسارات قبل المتابعة: \u2068{paths}\u2069",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "تصدير بصيغة PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "اختر تخطيط الصفحة لإنشاء ملف PDF متقن ومستقل.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "لا تُنزّل الصور البعيدة أثناء التصدير. تُضمّن الصور المحلية عند توفرها.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "حجم الصفحة", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "Letter (رسائل)", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "الاتجاه", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "عمودي", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "أفقي", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "الهوامش", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "ضيقة", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "عادية", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "عريضة", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "تضمين أرقام الصفحات", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "تصدير", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "جارٍ تصدير PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "مستند PDF", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "تم تصدير {fileName}.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "تم تصدير {fileName}. صور تعذر تضمينها: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "مكوّن تصدير PDF مفقود. أعد تثبيت BusyMark ثم حاول مجددًا.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "استغرق تصدير PDF وقتًا طويلًا جدًا وتم إيقافه.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "تعذر على BusyMark تصدير هذا المستند بصيغة PDF.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "تصدير مستند Markdown النشط بصيغة PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index 56505b0..b1be88d 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -2379,6 +2379,52 @@
"errorWritersideRedirectInvalid": "Das ausgewählte Weiterleitungsziel ist nicht mehr gültig. Wählen Sie es erneut aus.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "Das Entfernen des Themas konnte nicht vollständig rückgängig gemacht werden. Prüfen Sie vor dem Fortfahren diese Pfade: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Als PDF exportieren",
+ "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Wählen Sie das Seitenlayout für eine professionelle, eigenständige PDF-Datei.",
+ "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Remote Bilder werden beim Export nicht heruntergeladen. Lokale Bilder werden einbezogen, wenn sie verfügbar sind.",
+ "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Seitengröße",
+ "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4",
+ "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "US-Letter",
+ "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Ausrichtung",
+ "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Hochformat",
+ "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Querformat",
+ "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Ränder",
+ "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Schmal",
+ "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Standard",
+ "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Breit",
+ "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Seitenzahlen einfügen",
+ "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Exportieren",
+ "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "PDF wird exportiert…",
+ "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "PDF-Dokument",
+ "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} wurde exportiert.",
+ "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} wurde exportiert. Nicht einbezogene Bilder: {count}.",
+ "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "Die PDF-Exportkomponente fehlt. Installieren Sie BusyMark neu und versuchen Sie es erneut.",
+ "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "Der PDF-Export dauerte zu lange und wurde beendet.",
+ "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark konnte dieses Dokument nicht als PDF exportieren.",
+ "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Das aktive Markdown-Dokument als PDF exportieren.",
+ "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 62df6b7..1479b0e 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1594,6 +1594,52 @@
"markdownHtmlSafeUrls": "Safe URLs only",
"@markdownHtmlSafeUrls": {"description": "Reference row label for safe URL policy."},
"markdownHtmlSafeUrlsDescription": "Links allow http, https, mailto, tel, relative, and fragment URLs; unsafe schemes are blocked.",
- "@markdownHtmlSafeUrlsDescription": {"description": "Reference row description for safe URL policy."}
+ "@markdownHtmlSafeUrlsDescription": {"description": "Reference row description for safe URL policy."},
+ "exportAsPdf": "Export as PDF",
+ "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Choose the page layout for a polished, self-contained PDF.",
+ "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Remote images are not downloaded during export. Local images are included when available.",
+ "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Page size",
+ "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4",
+ "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "Letter",
+ "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Orientation",
+ "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Portrait",
+ "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Landscape",
+ "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Margins",
+ "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Narrow",
+ "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Normal",
+ "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Wide",
+ "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Include page numbers",
+ "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Export",
+ "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "Exporting PDF…",
+ "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "PDF document",
+ "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} was exported.",
+ "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} was exported. Images that could not be included: {count}.",
+ "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "The PDF export component is missing. Reinstall BusyMark and try again.",
+ "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "PDF export took too long and was stopped.",
+ "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark could not export this document as PDF.",
+ "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Export the active Markdown document as a PDF.",
+ "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index 68a7996..9bb7acd 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -2379,6 +2379,52 @@
"errorWritersideRedirectInvalid": "El destino de redirección seleccionado ya no es válido. Vuelve a seleccionarlo.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "No se pudo revertir por completo la eliminación del tema. Revisa estas rutas antes de continuar: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Exportar como PDF",
+ "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Elige el diseño de página para crear un PDF pulido e independiente.",
+ "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Las imágenes remotas no se descargan durante la exportación. Las imágenes locales se incluyen cuando están disponibles.",
+ "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Tamaño de página",
+ "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4",
+ "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "Carta",
+ "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Orientación",
+ "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Vertical",
+ "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Horizontal",
+ "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Márgenes",
+ "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Estrechos",
+ "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Normales",
+ "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Amplios",
+ "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Incluir números de página",
+ "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Exportar",
+ "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "Exportando PDF…",
+ "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "Documento PDF",
+ "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "Se exportó {fileName}.",
+ "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "Se exportó {fileName}. Imágenes que no se pudieron incluir: {count}.",
+ "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "Falta el componente de exportación a PDF. Reinstala BusyMark e inténtalo de nuevo.",
+ "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "La exportación a PDF tardó demasiado y se detuvo.",
+ "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark no pudo exportar este documento como PDF.",
+ "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Exportar el documento Markdown activo como PDF.",
+ "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb
index c122ed7..256c12a 100644
--- a/lib/l10n/app_et.arb
+++ b/lib/l10n/app_et.arb
@@ -1594,6 +1594,29 @@
"markdownHtmlSafeUrls": "Ainult turvalised URL-id",
"@markdownHtmlSafeUrls": {"description": "Reference row label for safe URL policy."},
"markdownHtmlSafeUrlsDescription": "Linkides on lubatud HTTP-, HTTPS-, mailto- ja tel-skeemiga URL-id ning suhtelised ja fragmendi-URL-id; ebaturvalised skeemid blokeeritakse.",
- "@markdownHtmlSafeUrlsDescription": {"description": "Reference row description for safe URL policy."}
+ "@markdownHtmlSafeUrlsDescription": {"description": "Reference row description for safe URL policy."},
+ "exportAsPdf": "Ekspordi PDF-ina", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Vali viimistletud ja iseseisva PDF-i leheküljendus.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Kaugpilte eksportimisel alla ei laadita. Kohalikud pildid lisatakse, kui need on saadaval.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Lehe suurus", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "USA Letter", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Paigutus", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Püstpaigutus", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Rõhtpaigutus", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Veerised", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Kitsad", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Tavalised", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Laiad", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Lisa leheküljenumbrid", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Ekspordi", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "PDF-i eksportimine…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "PDF-dokument", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} eksporditi.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} eksporditi. Lisamata jäänud pilte: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "PDF-i ekspordikomponent puudub. Paigalda BusyMark uuesti ja proovi veel kord.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "PDF-i eksport võttis liiga kaua aega ja peatati.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark ei saanud seda dokumenti PDF-ina eksportida.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Ekspordi aktiivne Markdowni dokument PDF-ina.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb
index b9e196b..f03790d 100644
--- a/lib/l10n/app_fa.arb
+++ b/lib/l10n/app_fa.arb
@@ -2400,6 +2400,29 @@
"errorWritersideRedirectInvalid": "مقصد تغییر مسیر انتخابشده دیگر معتبر نیست. دوباره آن را انتخاب کنید.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "حذف موضوع بهطور کامل بازگردانده نشد. پیش از ادامه این مسیرها را بررسی کنید: \u2068{paths}\u2069",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "خروجی بهصورت PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "چیدمان صفحه را برای یک PDF حرفهای و مستقل انتخاب کنید.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "تصاویر راهدور هنگام خروجی دانلود نمیشوند. تصاویر محلی در صورت دسترس بودن افزوده میشوند.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "اندازه صفحه", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "Letter (نامه)", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "جهت", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "عمودی", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "افقی", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "حاشیهها", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "باریک", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "عادی", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "پهن", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "افزودن شماره صفحه", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "خروجی", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "در حال تهیه PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "سند PDF", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} صادر شد.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} صادر شد. تصاویر افزودهنشده: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "مؤلفه خروجی PDF موجود نیست. BusyMark را دوباره نصب و تلاش کنید.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "خروجی PDF بیش از حد طول کشید و متوقف شد.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark نتوانست این سند را به PDF تبدیل کند.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "سند Markdown فعال را به PDF صادر کنید.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 14fe097..b204103 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -2379,6 +2379,52 @@
"errorWritersideRedirectInvalid": "La cible de redirection sélectionnée n’est plus valide. Sélectionnez-la de nouveau.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "La suppression du sujet n’a pas pu être entièrement annulée. Vérifiez ces chemins avant de continuer : {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Exporter en PDF",
+ "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Choisissez la mise en page pour obtenir un PDF soigné et autonome.",
+ "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Les images distantes ne sont pas téléchargées pendant l’exportation. Les images locales sont incluses lorsqu’elles sont disponibles.",
+ "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Format de page",
+ "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4",
+ "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "Lettre",
+ "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Orientation",
+ "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Portrait",
+ "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Paysage",
+ "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Marges",
+ "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Étroites",
+ "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Normales",
+ "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Larges",
+ "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Inclure les numéros de page",
+ "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Exporter",
+ "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "Exportation du PDF…",
+ "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "Document PDF",
+ "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} a été exporté.",
+ "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} a été exporté. Images non incluses : {count}.",
+ "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "Le composant d’exportation PDF est manquant. Réinstallez BusyMark et réessayez.",
+ "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "L’exportation PDF a pris trop de temps et a été arrêtée.",
+ "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark n’a pas pu exporter ce document en PDF.",
+ "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Exporter le document Markdown actif en PDF.",
+ "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb
index 8ccc37f..6685695 100644
--- a/lib/l10n/app_hi.arb
+++ b/lib/l10n/app_hi.arb
@@ -2381,6 +2381,29 @@
"errorWritersideRedirectInvalid": "चुना गया रीडायरेक्ट लक्ष्य अब मान्य नहीं है। उसे फिर से चुनें।",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "विषय हटाने की कार्रवाई पूरी तरह वापस नहीं की जा सकी। आगे बढ़ने से पहले इन पथों की समीक्षा करें: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "PDF के रूप में निर्यात करें", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "सुंदर और स्व-निहित PDF के लिए पृष्ठ लेआउट चुनें।", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "निर्यात के दौरान दूरस्थ चित्र डाउनलोड नहीं किए जाते। उपलब्ध स्थानीय चित्र शामिल किए जाते हैं।", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "पृष्ठ आकार", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "लेटर", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "दिशा", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "पोर्ट्रेट", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "लैंडस्केप", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "हाशिए", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "संकीर्ण", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "सामान्य", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "चौड़े", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "पृष्ठ संख्याएँ शामिल करें", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "निर्यात करें", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "PDF निर्यात हो रहा है…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "PDF दस्तावेज़", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} निर्यात किया गया।", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} निर्यात किया गया। शामिल न हो सकने वाले चित्र: {count}।", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "PDF निर्यात घटक उपलब्ध नहीं है। BusyMark को फिर स्थापित करके दोबारा प्रयास करें।", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "PDF निर्यात में बहुत समय लगा और इसे रोक दिया गया।", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark इस दस्तावेज़ को PDF के रूप में निर्यात नहीं कर सका।", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "सक्रिय Markdown दस्तावेज़ को PDF के रूप में निर्यात करें।", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 51387cb..046cc95 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -2379,6 +2379,29 @@
"errorWritersideRedirectInvalid": "La destinazione di reindirizzamento selezionata non è più valida. Selezionala di nuovo.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "Non è stato possibile annullare completamente la rimozione dell’argomento. Controlla questi percorsi prima di continuare: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Esporta come PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Scegli l’impaginazione per creare un PDF rifinito e autonomo.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Le immagini remote non vengono scaricate durante l’esportazione. Le immagini locali vengono incluse quando disponibili.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Formato pagina", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "Lettera", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Orientamento", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Verticale", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Orizzontale", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Margini", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Stretti", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Normali", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Ampi", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Includi numeri di pagina", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Esporta", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "Esportazione PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "Documento PDF", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} è stato esportato.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} è stato esportato. Immagini non incluse: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "Il componente di esportazione PDF non è disponibile. Reinstalla BusyMark e riprova.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "L’esportazione PDF ha richiesto troppo tempo ed è stata interrotta.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark non ha potuto esportare questo documento come PDF.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Esporta il documento Markdown attivo come PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb
index 5a09a4c..4eb6096 100644
--- a/lib/l10n/app_nb.arb
+++ b/lib/l10n/app_nb.arb
@@ -2379,6 +2379,29 @@
"errorWritersideRedirectInvalid": "Det valgte målet for videresending er ikke lenger gyldig. Velg det på nytt.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "Fjerningen av emnet kunne ikke angres fullstendig. Se gjennom disse banene før du fortsetter: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Eksporter som PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Velg sideoppsett for en gjennomarbeidet, selvstendig PDF.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Eksterne bilder lastes ikke ned under eksport. Lokale bilder tas med når de er tilgjengelige.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Sidestørrelse", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "US Letter", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Retning", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Stående", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Liggende", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Marger", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Smale", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Normale", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Brede", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Ta med sidetall", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Eksporter", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "Eksporterer PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "PDF-dokument", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} ble eksportert.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} ble eksportert. Bilder som ikke kunne tas med: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "PDF-eksportkomponenten mangler. Installer BusyMark på nytt og prøv igjen.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "PDF-eksporten tok for lang tid og ble stoppet.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark kunne ikke eksportere dette dokumentet som PDF.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Eksporter det aktive Markdown-dokumentet som PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index b85c965..ebf2325 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -2397,6 +2397,29 @@
"errorWritersideRedirectInvalid": "Wybrany cel przekierowania nie jest już prawidłowy. Wybierz go ponownie.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "Nie udało się całkowicie wycofać usunięcia tematu. Przed kontynuowaniem przejrzyj te ścieżki: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Eksportuj jako PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Wybierz układ strony dla dopracowanego, samodzielnego pliku PDF.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Obrazy zdalne nie są pobierane podczas eksportu. Dostępne obrazy lokalne zostaną dołączone.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Rozmiar strony", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "US Letter", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Orientacja", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Pionowa", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Pozioma", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Marginesy", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Wąskie", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Normalne", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Szerokie", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Dodaj numery stron", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Eksportuj", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "Eksportowanie PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "Dokument PDF", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "Wyeksportowano {fileName}.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "Wyeksportowano {fileName}. Obrazy, których nie udało się dołączyć: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "Brakuje składnika eksportu PDF. Zainstaluj ponownie BusyMark i spróbuj jeszcze raz.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "Eksport PDF trwał zbyt długo i został zatrzymany.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark nie mógł wyeksportować tego dokumentu jako PDF.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Eksportuj aktywny dokument Markdown jako PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index df5ea2e..65b12ff 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -2379,6 +2379,29 @@
"errorWritersideRedirectInvalid": "O destino de redirecionamento selecionado não é mais válido. Selecione-o novamente.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "Não foi possível reverter completamente a remoção do tópico. Revise estes caminhos antes de continuar: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Exportar como PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Escolha o esquema da página para criar um PDF bem acabado e independente.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "As imagens remotas não são transferidas durante a exportação. As imagens locais são incluídas quando disponíveis.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Tamanho da página", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "Carta", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Orientação", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Vertical", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Horizontal", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Margens", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Estreitas", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Normais", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Largas", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Incluir números de página", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Exportar", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "A exportar PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "Documento PDF", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "{fileName} foi exportado.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "{fileName} foi exportado. Imagens que não foi possível incluir: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "O componente de exportação para PDF está em falta. Reinstale o BusyMark e tente novamente.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "A exportação para PDF demorou demasiado e foi interrompida.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "O BusyMark não conseguiu exportar este documento como PDF.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Exportar o documento Markdown ativo como PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 5ec9fca..0ea5e3b 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -2397,6 +2397,29 @@
"errorWritersideRedirectInvalid": "Выбранная цель перенаправления больше недействительна. Выберите её снова.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "Не удалось полностью откатить удаление темы. Перед продолжением проверьте следующие пути: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Экспортировать в PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Выберите макет страницы для аккуратного автономного PDF-файла.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Удалённые изображения при экспорте не загружаются. Доступные локальные изображения будут добавлены.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Размер страницы", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "US Letter", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Ориентация", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Книжная", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Альбомная", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Поля", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Узкие", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Обычные", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Широкие", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Добавить номера страниц", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Экспортировать", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "Экспорт PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "Документ PDF", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "Файл {fileName} экспортирован.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "Файл {fileName} экспортирован. Не удалось добавить изображений: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "Компонент экспорта PDF отсутствует. Переустановите BusyMark и повторите попытку.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "Экспорт PDF занял слишком много времени и был остановлен.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark не удалось экспортировать этот документ в PDF.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Экспортировать активный документ Markdown в PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb
index fe36fd2..ac0905a 100644
--- a/lib/l10n/app_uk.arb
+++ b/lib/l10n/app_uk.arb
@@ -2397,6 +2397,29 @@
"errorWritersideRedirectInvalid": "Вибрана ціль переспрямування більше не дійсна. Виберіть її знову.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "Не вдалося повністю відкотити видалення теми. Перш ніж продовжити, перевірте такі шляхи: {paths}",
- "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
+ "@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
+ "exportAsPdf": "Експортувати як PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
+ "pdfExportDescription": "Виберіть макет сторінки для охайного автономного PDF-файлу.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
+ "pdfRemoteImagesNote": "Віддалені зображення під час експорту не завантажуються. Доступні локальні зображення буде додано.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
+ "pdfPageSize": "Розмір сторінки", "@pdfPageSize": {"description": "PDF export page-size setting."},
+ "pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
+ "pdfPageSizeLetter": "US Letter", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
+ "pdfOrientation": "Орієнтація", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
+ "pdfPortrait": "Книжкова", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
+ "pdfLandscape": "Альбомна", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
+ "pdfMargins": "Поля", "@pdfMargins": {"description": "PDF export page-margin setting."},
+ "pdfMarginNarrow": "Вузькі", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
+ "pdfMarginNormal": "Звичайні", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
+ "pdfMarginWide": "Широкі", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
+ "pdfIncludePageNumbers": "Додати номери сторінок", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
+ "export": "Експортувати", "@export": {"description": "Button label that starts an export."},
+ "exportingPdf": "Експорт PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
+ "fileTypePdf": "Документ PDF", "@fileTypePdf": {"description": "File picker label for PDF documents."},
+ "pdfExported": "Файл {fileName} експортовано.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
+ "pdfExportedWithWarnings": "Файл {fileName} експортовано. Не вдалося додати зображень: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
+ "pdfExportUnavailable": "Компонент експорту PDF відсутній. Перевстановіть BusyMark і повторіть спробу.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
+ "pdfExportTimedOut": "Експорт PDF тривав надто довго й був зупинений.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
+ "pdfExportFailed": "BusyMark не вдалося експортувати цей документ як PDF.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
+ "shortcutExportPdfDescription": "Експортувати активний документ Markdown як PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}
}
diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart
index 651a761..f54944a 100644
--- a/lib/l10n/generated/app_localizations.dart
+++ b/lib/l10n/generated/app_localizations.dart
@@ -3949,6 +3949,144 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Links allow http, https, mailto, tel, relative, and fragment URLs; unsafe schemes are blocked.'**
String get markdownHtmlSafeUrlsDescription;
+
+ /// Menu action and dialog title for exporting the active Markdown document as PDF.
+ ///
+ /// In en, this message translates to:
+ /// **'Export as PDF'**
+ String get exportAsPdf;
+
+ /// Introductory text in the PDF export options dialog.
+ ///
+ /// In en, this message translates to:
+ /// **'Choose the page layout for a polished, self-contained PDF.'**
+ String get pdfExportDescription;
+
+ /// Privacy note explaining image handling during PDF export.
+ ///
+ /// In en, this message translates to:
+ /// **'Remote images are not downloaded during export. Local images are included when available.'**
+ String get pdfRemoteImagesNote;
+
+ /// PDF export page-size setting.
+ ///
+ /// In en, this message translates to:
+ /// **'Page size'**
+ String get pdfPageSize;
+
+ /// A4 PDF page-size option.
+ ///
+ /// In en, this message translates to:
+ /// **'A4'**
+ String get pdfPageSizeA4;
+
+ /// US Letter PDF page-size option.
+ ///
+ /// In en, this message translates to:
+ /// **'Letter'**
+ String get pdfPageSizeLetter;
+
+ /// PDF export page-orientation setting.
+ ///
+ /// In en, this message translates to:
+ /// **'Orientation'**
+ String get pdfOrientation;
+
+ /// Portrait PDF page-orientation option.
+ ///
+ /// In en, this message translates to:
+ /// **'Portrait'**
+ String get pdfPortrait;
+
+ /// Landscape PDF page-orientation option.
+ ///
+ /// In en, this message translates to:
+ /// **'Landscape'**
+ String get pdfLandscape;
+
+ /// PDF export page-margin setting.
+ ///
+ /// In en, this message translates to:
+ /// **'Margins'**
+ String get pdfMargins;
+
+ /// Narrow PDF page-margin option.
+ ///
+ /// In en, this message translates to:
+ /// **'Narrow'**
+ String get pdfMarginNarrow;
+
+ /// Normal PDF page-margin option.
+ ///
+ /// In en, this message translates to:
+ /// **'Normal'**
+ String get pdfMarginNormal;
+
+ /// Wide PDF page-margin option.
+ ///
+ /// In en, this message translates to:
+ /// **'Wide'**
+ String get pdfMarginWide;
+
+ /// Toggle for page numbers in an exported PDF.
+ ///
+ /// In en, this message translates to:
+ /// **'Include page numbers'**
+ String get pdfIncludePageNumbers;
+
+ /// Button label that starts an export.
+ ///
+ /// In en, this message translates to:
+ /// **'Export'**
+ String get export;
+
+ /// Progress dialog title while a PDF is being exported.
+ ///
+ /// In en, this message translates to:
+ /// **'Exporting PDF…'**
+ String get exportingPdf;
+
+ /// File picker label for PDF documents.
+ ///
+ /// In en, this message translates to:
+ /// **'PDF document'**
+ String get fileTypePdf;
+
+ /// PDF export success message.
+ ///
+ /// In en, this message translates to:
+ /// **'{fileName} was exported.'**
+ String pdfExported(String fileName);
+
+ /// PDF export success message when some images were omitted.
+ ///
+ /// In en, this message translates to:
+ /// **'{fileName} was exported. Images that could not be included: {count}.'**
+ String pdfExportedWithWarnings(String fileName, int count);
+
+ /// Error shown when the bundled PDF compiler is unavailable.
+ ///
+ /// In en, this message translates to:
+ /// **'The PDF export component is missing. Reinstall BusyMark and try again.'**
+ String get pdfExportUnavailable;
+
+ /// Error shown when PDF compilation times out.
+ ///
+ /// In en, this message translates to:
+ /// **'PDF export took too long and was stopped.'**
+ String get pdfExportTimedOut;
+
+ /// Generic PDF export failure message.
+ ///
+ /// In en, this message translates to:
+ /// **'BusyMark could not export this document as PDF.'**
+ String get pdfExportFailed;
+
+ /// Keyboard-shortcut description for PDF export.
+ ///
+ /// In en, this message translates to:
+ /// **'Export the active Markdown document as a PDF.'**
+ String get shortcutExportPdfDescription;
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart
index e8a7e57..4c00b84 100644
--- a/lib/l10n/generated/app_localizations_ar.dart
+++ b/lib/l10n/generated/app_localizations_ar.dart
@@ -2355,4 +2355,83 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'تسمح الروابط بـ http وhttps وmailto وtel والروابط النسبية والمقاطع؛ وتحظر المخططات غير الآمنة.';
+
+ @override
+ String get exportAsPdf => 'تصدير بصيغة PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'اختر تخطيط الصفحة لإنشاء ملف PDF متقن ومستقل.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'لا تُنزّل الصور البعيدة أثناء التصدير. تُضمّن الصور المحلية عند توفرها.';
+
+ @override
+ String get pdfPageSize => 'حجم الصفحة';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'Letter (رسائل)';
+
+ @override
+ String get pdfOrientation => 'الاتجاه';
+
+ @override
+ String get pdfPortrait => 'عمودي';
+
+ @override
+ String get pdfLandscape => 'أفقي';
+
+ @override
+ String get pdfMargins => 'الهوامش';
+
+ @override
+ String get pdfMarginNarrow => 'ضيقة';
+
+ @override
+ String get pdfMarginNormal => 'عادية';
+
+ @override
+ String get pdfMarginWide => 'عريضة';
+
+ @override
+ String get pdfIncludePageNumbers => 'تضمين أرقام الصفحات';
+
+ @override
+ String get export => 'تصدير';
+
+ @override
+ String get exportingPdf => 'جارٍ تصدير PDF…';
+
+ @override
+ String get fileTypePdf => 'مستند PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return 'تم تصدير $fileName.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return 'تم تصدير $fileName. صور تعذر تضمينها: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'مكوّن تصدير PDF مفقود. أعد تثبيت BusyMark ثم حاول مجددًا.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'استغرق تصدير PDF وقتًا طويلًا جدًا وتم إيقافه.';
+
+ @override
+ String get pdfExportFailed =>
+ 'تعذر على BusyMark تصدير هذا المستند بصيغة PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'تصدير مستند Markdown النشط بصيغة PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart
index 7800990..a66c7cb 100644
--- a/lib/l10n/generated/app_localizations_de.dart
+++ b/lib/l10n/generated/app_localizations_de.dart
@@ -2358,4 +2358,83 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Links erlauben http, https, mailto, tel, relative URLs und Fragmente; unsichere URL-Schemata werden blockiert.';
+
+ @override
+ String get exportAsPdf => 'Als PDF exportieren';
+
+ @override
+ String get pdfExportDescription =>
+ 'Wählen Sie das Seitenlayout für eine professionelle, eigenständige PDF-Datei.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Remote Bilder werden beim Export nicht heruntergeladen. Lokale Bilder werden einbezogen, wenn sie verfügbar sind.';
+
+ @override
+ String get pdfPageSize => 'Seitengröße';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'US-Letter';
+
+ @override
+ String get pdfOrientation => 'Ausrichtung';
+
+ @override
+ String get pdfPortrait => 'Hochformat';
+
+ @override
+ String get pdfLandscape => 'Querformat';
+
+ @override
+ String get pdfMargins => 'Ränder';
+
+ @override
+ String get pdfMarginNarrow => 'Schmal';
+
+ @override
+ String get pdfMarginNormal => 'Standard';
+
+ @override
+ String get pdfMarginWide => 'Breit';
+
+ @override
+ String get pdfIncludePageNumbers => 'Seitenzahlen einfügen';
+
+ @override
+ String get export => 'Exportieren';
+
+ @override
+ String get exportingPdf => 'PDF wird exportiert…';
+
+ @override
+ String get fileTypePdf => 'PDF-Dokument';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName wurde exportiert.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName wurde exportiert. Nicht einbezogene Bilder: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'Die PDF-Exportkomponente fehlt. Installieren Sie BusyMark neu und versuchen Sie es erneut.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'Der PDF-Export dauerte zu lange und wurde beendet.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark konnte dieses Dokument nicht als PDF exportieren.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Das aktive Markdown-Dokument als PDF exportieren.';
}
diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart
index fac59c4..dc57ed9 100644
--- a/lib/l10n/generated/app_localizations_en.dart
+++ b/lib/l10n/generated/app_localizations_en.dart
@@ -2327,4 +2327,82 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Links allow http, https, mailto, tel, relative, and fragment URLs; unsafe schemes are blocked.';
+
+ @override
+ String get exportAsPdf => 'Export as PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Choose the page layout for a polished, self-contained PDF.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Remote images are not downloaded during export. Local images are included when available.';
+
+ @override
+ String get pdfPageSize => 'Page size';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'Letter';
+
+ @override
+ String get pdfOrientation => 'Orientation';
+
+ @override
+ String get pdfPortrait => 'Portrait';
+
+ @override
+ String get pdfLandscape => 'Landscape';
+
+ @override
+ String get pdfMargins => 'Margins';
+
+ @override
+ String get pdfMarginNarrow => 'Narrow';
+
+ @override
+ String get pdfMarginNormal => 'Normal';
+
+ @override
+ String get pdfMarginWide => 'Wide';
+
+ @override
+ String get pdfIncludePageNumbers => 'Include page numbers';
+
+ @override
+ String get export => 'Export';
+
+ @override
+ String get exportingPdf => 'Exporting PDF…';
+
+ @override
+ String get fileTypePdf => 'PDF document';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName was exported.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName was exported. Images that could not be included: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'The PDF export component is missing. Reinstall BusyMark and try again.';
+
+ @override
+ String get pdfExportTimedOut => 'PDF export took too long and was stopped.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark could not export this document as PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Export the active Markdown document as a PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart
index 107049e..fb9c015 100644
--- a/lib/l10n/generated/app_localizations_es.dart
+++ b/lib/l10n/generated/app_localizations_es.dart
@@ -2361,4 +2361,83 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Los enlaces permiten http, https, mailto, tel, URLs relativas y fragmentos; los esquemas inseguros se bloquean.';
+
+ @override
+ String get exportAsPdf => 'Exportar como PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Elige el diseño de página para crear un PDF pulido e independiente.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Las imágenes remotas no se descargan durante la exportación. Las imágenes locales se incluyen cuando están disponibles.';
+
+ @override
+ String get pdfPageSize => 'Tamaño de página';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'Carta';
+
+ @override
+ String get pdfOrientation => 'Orientación';
+
+ @override
+ String get pdfPortrait => 'Vertical';
+
+ @override
+ String get pdfLandscape => 'Horizontal';
+
+ @override
+ String get pdfMargins => 'Márgenes';
+
+ @override
+ String get pdfMarginNarrow => 'Estrechos';
+
+ @override
+ String get pdfMarginNormal => 'Normales';
+
+ @override
+ String get pdfMarginWide => 'Amplios';
+
+ @override
+ String get pdfIncludePageNumbers => 'Incluir números de página';
+
+ @override
+ String get export => 'Exportar';
+
+ @override
+ String get exportingPdf => 'Exportando PDF…';
+
+ @override
+ String get fileTypePdf => 'Documento PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return 'Se exportó $fileName.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return 'Se exportó $fileName. Imágenes que no se pudieron incluir: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'Falta el componente de exportación a PDF. Reinstala BusyMark e inténtalo de nuevo.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'La exportación a PDF tardó demasiado y se detuvo.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark no pudo exportar este documento como PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Exportar el documento Markdown activo como PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart
index ac650c0..0ef6974 100644
--- a/lib/l10n/generated/app_localizations_et.dart
+++ b/lib/l10n/generated/app_localizations_et.dart
@@ -2332,4 +2332,83 @@ class AppLocalizationsEt extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Linkides on lubatud HTTP-, HTTPS-, mailto- ja tel-skeemiga URL-id ning suhtelised ja fragmendi-URL-id; ebaturvalised skeemid blokeeritakse.';
+
+ @override
+ String get exportAsPdf => 'Ekspordi PDF-ina';
+
+ @override
+ String get pdfExportDescription =>
+ 'Vali viimistletud ja iseseisva PDF-i leheküljendus.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Kaugpilte eksportimisel alla ei laadita. Kohalikud pildid lisatakse, kui need on saadaval.';
+
+ @override
+ String get pdfPageSize => 'Lehe suurus';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'USA Letter';
+
+ @override
+ String get pdfOrientation => 'Paigutus';
+
+ @override
+ String get pdfPortrait => 'Püstpaigutus';
+
+ @override
+ String get pdfLandscape => 'Rõhtpaigutus';
+
+ @override
+ String get pdfMargins => 'Veerised';
+
+ @override
+ String get pdfMarginNarrow => 'Kitsad';
+
+ @override
+ String get pdfMarginNormal => 'Tavalised';
+
+ @override
+ String get pdfMarginWide => 'Laiad';
+
+ @override
+ String get pdfIncludePageNumbers => 'Lisa leheküljenumbrid';
+
+ @override
+ String get export => 'Ekspordi';
+
+ @override
+ String get exportingPdf => 'PDF-i eksportimine…';
+
+ @override
+ String get fileTypePdf => 'PDF-dokument';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName eksporditi.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName eksporditi. Lisamata jäänud pilte: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'PDF-i ekspordikomponent puudub. Paigalda BusyMark uuesti ja proovi veel kord.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'PDF-i eksport võttis liiga kaua aega ja peatati.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark ei saanud seda dokumenti PDF-ina eksportida.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Ekspordi aktiivne Markdowni dokument PDF-ina.';
}
diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart
index 2099d7e..b655bba 100644
--- a/lib/l10n/generated/app_localizations_fa.dart
+++ b/lib/l10n/generated/app_localizations_fa.dart
@@ -2387,4 +2387,81 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'پیوندها http، https، mailto، tel، URLهای نسبی و قطعهها را میپذیرند؛ طرحهای ناامن مسدود میشوند.';
+
+ @override
+ String get exportAsPdf => 'خروجی بهصورت PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'چیدمان صفحه را برای یک PDF حرفهای و مستقل انتخاب کنید.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'تصاویر راهدور هنگام خروجی دانلود نمیشوند. تصاویر محلی در صورت دسترس بودن افزوده میشوند.';
+
+ @override
+ String get pdfPageSize => 'اندازه صفحه';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'Letter (نامه)';
+
+ @override
+ String get pdfOrientation => 'جهت';
+
+ @override
+ String get pdfPortrait => 'عمودی';
+
+ @override
+ String get pdfLandscape => 'افقی';
+
+ @override
+ String get pdfMargins => 'حاشیهها';
+
+ @override
+ String get pdfMarginNarrow => 'باریک';
+
+ @override
+ String get pdfMarginNormal => 'عادی';
+
+ @override
+ String get pdfMarginWide => 'پهن';
+
+ @override
+ String get pdfIncludePageNumbers => 'افزودن شماره صفحه';
+
+ @override
+ String get export => 'خروجی';
+
+ @override
+ String get exportingPdf => 'در حال تهیه PDF…';
+
+ @override
+ String get fileTypePdf => 'سند PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName صادر شد.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName صادر شد. تصاویر افزودهنشده: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'مؤلفه خروجی PDF موجود نیست. BusyMark را دوباره نصب و تلاش کنید.';
+
+ @override
+ String get pdfExportTimedOut => 'خروجی PDF بیش از حد طول کشید و متوقف شد.';
+
+ @override
+ String get pdfExportFailed => 'BusyMark نتوانست این سند را به PDF تبدیل کند.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'سند Markdown فعال را به PDF صادر کنید.';
}
diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart
index 6216c48..61caff6 100644
--- a/lib/l10n/generated/app_localizations_fr.dart
+++ b/lib/l10n/generated/app_localizations_fr.dart
@@ -2351,4 +2351,83 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Les liens acceptent http, https, mailto, tel, les URL relatives et les fragments ; les schémas dangereux sont bloqués.';
+
+ @override
+ String get exportAsPdf => 'Exporter en PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Choisissez la mise en page pour obtenir un PDF soigné et autonome.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Les images distantes ne sont pas téléchargées pendant l’exportation. Les images locales sont incluses lorsqu’elles sont disponibles.';
+
+ @override
+ String get pdfPageSize => 'Format de page';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'Lettre';
+
+ @override
+ String get pdfOrientation => 'Orientation';
+
+ @override
+ String get pdfPortrait => 'Portrait';
+
+ @override
+ String get pdfLandscape => 'Paysage';
+
+ @override
+ String get pdfMargins => 'Marges';
+
+ @override
+ String get pdfMarginNarrow => 'Étroites';
+
+ @override
+ String get pdfMarginNormal => 'Normales';
+
+ @override
+ String get pdfMarginWide => 'Larges';
+
+ @override
+ String get pdfIncludePageNumbers => 'Inclure les numéros de page';
+
+ @override
+ String get export => 'Exporter';
+
+ @override
+ String get exportingPdf => 'Exportation du PDF…';
+
+ @override
+ String get fileTypePdf => 'Document PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName a été exporté.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName a été exporté. Images non incluses : $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'Le composant d’exportation PDF est manquant. Réinstallez BusyMark et réessayez.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'L’exportation PDF a pris trop de temps et a été arrêtée.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark n’a pas pu exporter ce document en PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Exporter le document Markdown actif en PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart
index 207f909..ed3a726 100644
--- a/lib/l10n/generated/app_localizations_hi.dart
+++ b/lib/l10n/generated/app_localizations_hi.dart
@@ -2325,4 +2325,83 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'लिंक http, https, mailto, tel, सापेक्ष URL और फ़्रैगमेंट स्वीकार करते हैं; असुरक्षित स्कीमें अवरुद्ध की जाती हैं।';
+
+ @override
+ String get exportAsPdf => 'PDF के रूप में निर्यात करें';
+
+ @override
+ String get pdfExportDescription =>
+ 'सुंदर और स्व-निहित PDF के लिए पृष्ठ लेआउट चुनें।';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'निर्यात के दौरान दूरस्थ चित्र डाउनलोड नहीं किए जाते। उपलब्ध स्थानीय चित्र शामिल किए जाते हैं।';
+
+ @override
+ String get pdfPageSize => 'पृष्ठ आकार';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'लेटर';
+
+ @override
+ String get pdfOrientation => 'दिशा';
+
+ @override
+ String get pdfPortrait => 'पोर्ट्रेट';
+
+ @override
+ String get pdfLandscape => 'लैंडस्केप';
+
+ @override
+ String get pdfMargins => 'हाशिए';
+
+ @override
+ String get pdfMarginNarrow => 'संकीर्ण';
+
+ @override
+ String get pdfMarginNormal => 'सामान्य';
+
+ @override
+ String get pdfMarginWide => 'चौड़े';
+
+ @override
+ String get pdfIncludePageNumbers => 'पृष्ठ संख्याएँ शामिल करें';
+
+ @override
+ String get export => 'निर्यात करें';
+
+ @override
+ String get exportingPdf => 'PDF निर्यात हो रहा है…';
+
+ @override
+ String get fileTypePdf => 'PDF दस्तावेज़';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName निर्यात किया गया।';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName निर्यात किया गया। शामिल न हो सकने वाले चित्र: $count।';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'PDF निर्यात घटक उपलब्ध नहीं है। BusyMark को फिर स्थापित करके दोबारा प्रयास करें।';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'PDF निर्यात में बहुत समय लगा और इसे रोक दिया गया।';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark इस दस्तावेज़ को PDF के रूप में निर्यात नहीं कर सका।';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'सक्रिय Markdown दस्तावेज़ को PDF के रूप में निर्यात करें।';
}
diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart
index 62717cf..e0c1cae 100644
--- a/lib/l10n/generated/app_localizations_it.dart
+++ b/lib/l10n/generated/app_localizations_it.dart
@@ -2352,4 +2352,83 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'I link consentono http, https, mailto, tel, URL relativi e frammenti; gli schemi non sicuri sono bloccati.';
+
+ @override
+ String get exportAsPdf => 'Esporta come PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Scegli l’impaginazione per creare un PDF rifinito e autonomo.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Le immagini remote non vengono scaricate durante l’esportazione. Le immagini locali vengono incluse quando disponibili.';
+
+ @override
+ String get pdfPageSize => 'Formato pagina';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'Lettera';
+
+ @override
+ String get pdfOrientation => 'Orientamento';
+
+ @override
+ String get pdfPortrait => 'Verticale';
+
+ @override
+ String get pdfLandscape => 'Orizzontale';
+
+ @override
+ String get pdfMargins => 'Margini';
+
+ @override
+ String get pdfMarginNarrow => 'Stretti';
+
+ @override
+ String get pdfMarginNormal => 'Normali';
+
+ @override
+ String get pdfMarginWide => 'Ampi';
+
+ @override
+ String get pdfIncludePageNumbers => 'Includi numeri di pagina';
+
+ @override
+ String get export => 'Esporta';
+
+ @override
+ String get exportingPdf => 'Esportazione PDF…';
+
+ @override
+ String get fileTypePdf => 'Documento PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName è stato esportato.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName è stato esportato. Immagini non incluse: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'Il componente di esportazione PDF non è disponibile. Reinstalla BusyMark e riprova.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'L’esportazione PDF ha richiesto troppo tempo ed è stata interrotta.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark non ha potuto esportare questo documento come PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Esporta il documento Markdown attivo come PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart
index fa2ff3c..d2aa559 100644
--- a/lib/l10n/generated/app_localizations_nb.dart
+++ b/lib/l10n/generated/app_localizations_nb.dart
@@ -2331,4 +2331,83 @@ class AppLocalizationsNb extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Lenker tillater http, https, mailto, tel, relative URL-er og fragmenter; usikre URI-skjemaer blokkeres.';
+
+ @override
+ String get exportAsPdf => 'Eksporter som PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Velg sideoppsett for en gjennomarbeidet, selvstendig PDF.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Eksterne bilder lastes ikke ned under eksport. Lokale bilder tas med når de er tilgjengelige.';
+
+ @override
+ String get pdfPageSize => 'Sidestørrelse';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'US Letter';
+
+ @override
+ String get pdfOrientation => 'Retning';
+
+ @override
+ String get pdfPortrait => 'Stående';
+
+ @override
+ String get pdfLandscape => 'Liggende';
+
+ @override
+ String get pdfMargins => 'Marger';
+
+ @override
+ String get pdfMarginNarrow => 'Smale';
+
+ @override
+ String get pdfMarginNormal => 'Normale';
+
+ @override
+ String get pdfMarginWide => 'Brede';
+
+ @override
+ String get pdfIncludePageNumbers => 'Ta med sidetall';
+
+ @override
+ String get export => 'Eksporter';
+
+ @override
+ String get exportingPdf => 'Eksporterer PDF…';
+
+ @override
+ String get fileTypePdf => 'PDF-dokument';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName ble eksportert.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName ble eksportert. Bilder som ikke kunne tas med: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'PDF-eksportkomponenten mangler. Installer BusyMark på nytt og prøv igjen.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'PDF-eksporten tok for lang tid og ble stoppet.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark kunne ikke eksportere dette dokumentet som PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Eksporter det aktive Markdown-dokumentet som PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart
index 13ebdf3..cc51b55 100644
--- a/lib/l10n/generated/app_localizations_pl.dart
+++ b/lib/l10n/generated/app_localizations_pl.dart
@@ -2371,4 +2371,83 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Łącza dopuszczają http, https, mailto, tel, względne adresy URL i fragmenty; niebezpieczne schematy są blokowane.';
+
+ @override
+ String get exportAsPdf => 'Eksportuj jako PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Wybierz układ strony dla dopracowanego, samodzielnego pliku PDF.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Obrazy zdalne nie są pobierane podczas eksportu. Dostępne obrazy lokalne zostaną dołączone.';
+
+ @override
+ String get pdfPageSize => 'Rozmiar strony';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'US Letter';
+
+ @override
+ String get pdfOrientation => 'Orientacja';
+
+ @override
+ String get pdfPortrait => 'Pionowa';
+
+ @override
+ String get pdfLandscape => 'Pozioma';
+
+ @override
+ String get pdfMargins => 'Marginesy';
+
+ @override
+ String get pdfMarginNarrow => 'Wąskie';
+
+ @override
+ String get pdfMarginNormal => 'Normalne';
+
+ @override
+ String get pdfMarginWide => 'Szerokie';
+
+ @override
+ String get pdfIncludePageNumbers => 'Dodaj numery stron';
+
+ @override
+ String get export => 'Eksportuj';
+
+ @override
+ String get exportingPdf => 'Eksportowanie PDF…';
+
+ @override
+ String get fileTypePdf => 'Dokument PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return 'Wyeksportowano $fileName.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return 'Wyeksportowano $fileName. Obrazy, których nie udało się dołączyć: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'Brakuje składnika eksportu PDF. Zainstaluj ponownie BusyMark i spróbuj jeszcze raz.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'Eksport PDF trwał zbyt długo i został zatrzymany.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark nie mógł wyeksportować tego dokumentu jako PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Eksportuj aktywny dokument Markdown jako PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart
index 0c92726..cf9c9d8 100644
--- a/lib/l10n/generated/app_localizations_pt.dart
+++ b/lib/l10n/generated/app_localizations_pt.dart
@@ -2347,4 +2347,83 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Links permitem http, https, mailto, tel, URLs relativas e fragmentos; esquemas inseguros são bloqueados.';
+
+ @override
+ String get exportAsPdf => 'Exportar como PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Escolha o esquema da página para criar um PDF bem acabado e independente.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'As imagens remotas não são transferidas durante a exportação. As imagens locais são incluídas quando disponíveis.';
+
+ @override
+ String get pdfPageSize => 'Tamanho da página';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'Carta';
+
+ @override
+ String get pdfOrientation => 'Orientação';
+
+ @override
+ String get pdfPortrait => 'Vertical';
+
+ @override
+ String get pdfLandscape => 'Horizontal';
+
+ @override
+ String get pdfMargins => 'Margens';
+
+ @override
+ String get pdfMarginNarrow => 'Estreitas';
+
+ @override
+ String get pdfMarginNormal => 'Normais';
+
+ @override
+ String get pdfMarginWide => 'Largas';
+
+ @override
+ String get pdfIncludePageNumbers => 'Incluir números de página';
+
+ @override
+ String get export => 'Exportar';
+
+ @override
+ String get exportingPdf => 'A exportar PDF…';
+
+ @override
+ String get fileTypePdf => 'Documento PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return '$fileName foi exportado.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return '$fileName foi exportado. Imagens que não foi possível incluir: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'O componente de exportação para PDF está em falta. Reinstale o BusyMark e tente novamente.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'A exportação para PDF demorou demasiado e foi interrompida.';
+
+ @override
+ String get pdfExportFailed =>
+ 'O BusyMark não conseguiu exportar este documento como PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Exportar o documento Markdown ativo como PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart
index 7727ddc..4fed9bb 100644
--- a/lib/l10n/generated/app_localizations_ru.dart
+++ b/lib/l10n/generated/app_localizations_ru.dart
@@ -2365,4 +2365,83 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Ссылки допускают http, https, mailto, tel, относительные URL и фрагменты; небезопасные схемы блокируются.';
+
+ @override
+ String get exportAsPdf => 'Экспортировать в PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Выберите макет страницы для аккуратного автономного PDF-файла.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Удалённые изображения при экспорте не загружаются. Доступные локальные изображения будут добавлены.';
+
+ @override
+ String get pdfPageSize => 'Размер страницы';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'US Letter';
+
+ @override
+ String get pdfOrientation => 'Ориентация';
+
+ @override
+ String get pdfPortrait => 'Книжная';
+
+ @override
+ String get pdfLandscape => 'Альбомная';
+
+ @override
+ String get pdfMargins => 'Поля';
+
+ @override
+ String get pdfMarginNarrow => 'Узкие';
+
+ @override
+ String get pdfMarginNormal => 'Обычные';
+
+ @override
+ String get pdfMarginWide => 'Широкие';
+
+ @override
+ String get pdfIncludePageNumbers => 'Добавить номера страниц';
+
+ @override
+ String get export => 'Экспортировать';
+
+ @override
+ String get exportingPdf => 'Экспорт PDF…';
+
+ @override
+ String get fileTypePdf => 'Документ PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return 'Файл $fileName экспортирован.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return 'Файл $fileName экспортирован. Не удалось добавить изображений: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'Компонент экспорта PDF отсутствует. Переустановите BusyMark и повторите попытку.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'Экспорт PDF занял слишком много времени и был остановлен.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark не удалось экспортировать этот документ в PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Экспортировать активный документ Markdown в PDF.';
}
diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart
index 7aa6963..7ab5c6c 100644
--- a/lib/l10n/generated/app_localizations_uk.dart
+++ b/lib/l10n/generated/app_localizations_uk.dart
@@ -2371,4 +2371,83 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get markdownHtmlSafeUrlsDescription =>
'Посилання дозволяють http, https, mailto, tel, відносні URL і фрагменти; небезпечні схеми блокуються.';
+
+ @override
+ String get exportAsPdf => 'Експортувати як PDF';
+
+ @override
+ String get pdfExportDescription =>
+ 'Виберіть макет сторінки для охайного автономного PDF-файлу.';
+
+ @override
+ String get pdfRemoteImagesNote =>
+ 'Віддалені зображення під час експорту не завантажуються. Доступні локальні зображення буде додано.';
+
+ @override
+ String get pdfPageSize => 'Розмір сторінки';
+
+ @override
+ String get pdfPageSizeA4 => 'A4';
+
+ @override
+ String get pdfPageSizeLetter => 'US Letter';
+
+ @override
+ String get pdfOrientation => 'Орієнтація';
+
+ @override
+ String get pdfPortrait => 'Книжкова';
+
+ @override
+ String get pdfLandscape => 'Альбомна';
+
+ @override
+ String get pdfMargins => 'Поля';
+
+ @override
+ String get pdfMarginNarrow => 'Вузькі';
+
+ @override
+ String get pdfMarginNormal => 'Звичайні';
+
+ @override
+ String get pdfMarginWide => 'Широкі';
+
+ @override
+ String get pdfIncludePageNumbers => 'Додати номери сторінок';
+
+ @override
+ String get export => 'Експортувати';
+
+ @override
+ String get exportingPdf => 'Експорт PDF…';
+
+ @override
+ String get fileTypePdf => 'Документ PDF';
+
+ @override
+ String pdfExported(String fileName) {
+ return 'Файл $fileName експортовано.';
+ }
+
+ @override
+ String pdfExportedWithWarnings(String fileName, int count) {
+ return 'Файл $fileName експортовано. Не вдалося додати зображень: $count.';
+ }
+
+ @override
+ String get pdfExportUnavailable =>
+ 'Компонент експорту PDF відсутній. Перевстановіть BusyMark і повторіть спробу.';
+
+ @override
+ String get pdfExportTimedOut =>
+ 'Експорт PDF тривав надто довго й був зупинений.';
+
+ @override
+ String get pdfExportFailed =>
+ 'BusyMark не вдалося експортувати цей документ як PDF.';
+
+ @override
+ String get shortcutExportPdfDescription =>
+ 'Експортувати активний документ Markdown як PDF.';
}
diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart
index 94760b3..dcb8061 100644
--- a/lib/src/app/app_metadata.dart
+++ b/lib/src/app/app_metadata.dart
@@ -1 +1 @@
-const busyMarkAppVersion = '0.2.3';
+const busyMarkAppVersion = '0.2.4';
diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart
index 45c80f4..ab7e6a0 100644
--- a/lib/src/app/busymark_app.dart
+++ b/lib/src/app/busymark_app.dart
@@ -10,6 +10,7 @@ import 'package:path/path.dart' as p;
import 'package:ubuntu_localizations/ubuntu_localizations.dart';
import '../../l10n/generated/app_localizations.dart';
+import '../export/markdown_pdf_export_ui.dart';
import '../git/application/git_controller.dart';
import '../platform/linux_header_bar_service.dart';
import '../workspace/workspace_controller.dart';
@@ -83,6 +84,8 @@ class BusyMarkApp extends ConsumerWidget {
BusyMarkAppShortcutActivators.open:
const _OpenWorkspaceIntent(),
BusyMarkAppShortcutActivators.save: const _SaveActiveIntent(),
+ BusyMarkAppShortcutActivators.exportPdf:
+ const _ExportPdfIntent(),
BusyMarkAppShortcutActivators.keyboardShortcuts:
const _KeyboardShortcutsIntent(),
BusyMarkAppShortcutActivators.settings: const _SettingsIntent(),
@@ -167,6 +170,19 @@ class BusyMarkApp extends ConsumerWidget {
return null;
},
),
+ _ExportPdfIntent: CallbackAction<_ExportPdfIntent>(
+ onInvoke: (intent) {
+ final state = ref.read(workspaceControllerProvider);
+ final navigatorContext = rootNavigatorKey.currentContext;
+ if (navigatorContext != null &&
+ canExportActiveMarkdown(state)) {
+ unawaited(
+ exportActiveMarkdownToPdf(navigatorContext, ref),
+ );
+ }
+ return null;
+ },
+ ),
_KeyboardShortcutsIntent:
CallbackAction<_KeyboardShortcutsIntent>(
onInvoke: (intent) {
@@ -607,6 +623,9 @@ class BusyMarkApp extends ConsumerWidget {
sidebarShortcut: BusyMarkSidebarShortcutLabels.toggleSidebar,
back: material.backButtonTooltip,
save: l10n.save,
+ exportPdf: l10n.exportAsPdf,
+ exportPdfShortcut: BusyMarkAppShortcutLabels.exportPdf,
+ exportPdfGtkAccelerator: BusyMarkAppShortcutGtkAccelerators.exportPdf,
settings: l10n.settings,
settingsShortcut: BusyMarkAppShortcutLabels.settings,
settingsGtkAccelerator: BusyMarkAppShortcutGtkAccelerators.settings,
@@ -828,6 +847,10 @@ class _SaveActiveIntent extends Intent {
const _SaveActiveIntent();
}
+class _ExportPdfIntent extends Intent {
+ const _ExportPdfIntent();
+}
+
class _KeyboardShortcutsIntent extends Intent {
const _KeyboardShortcutsIntent();
}
diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart
index 49a34e5..eaeb4ed 100644
--- a/lib/src/app/busymark_design.dart
+++ b/lib/src/app/busymark_design.dart
@@ -80,15 +80,17 @@ abstract final class BusyMarkSizes {
static const double sourceGutterWidth = 50;
static const double sourceFoldButton = 16;
static const double sourceFoldButtonRightInset = 1;
- static const double previewHeadingTop = 18;
- static const double previewHeadingBottom = 6;
- static const double previewListMarkerWidth = 18;
- static const double previewListMarkerTopInset = 2;
- static const double previewImageMinWidth = 80;
- static const double previewImageMaxWidth = documentContentWidth;
+ static const double documentHeadingTop = 18;
+ static const double documentHeadingBottom = 6;
+ static const double documentListMarkerWidth = 18;
+ static const double documentListMarkerTopInset = 2;
+ static const double documentListIndent =
+ documentListMarkerWidth + BusyMarkSpacing.sm;
+ static const double documentImageMinWidth = 80;
+ static const double documentImageMaxWidth = documentContentWidth;
+ static const double documentImageMinHeight = iconButton;
static const double previewInlineImageMaxHeight = 180;
static const double previewInlineImageHeight = 96;
- static const double wysiwygBlockIndent = 28;
static const double wysiwygToolbarReserve =
iconButton + BusyMarkSpacing.xs * 2;
static const double wysiwygToolbarClearance =
@@ -195,11 +197,11 @@ abstract final class BusyMarkTypography {
'DejaVu Sans',
];
static const double codeLineHeight = 1.45;
+ static const double sourceEditorLineHeight = 1.6;
static const double bodyLineHeight = 1.5;
static const double defaultFontSize = 14;
static const double tooltipFontSize = defaultFontSize;
- static const double previewThematicBreakHeight = BusyMarkStroke.thematicBreak;
- static const double sourceCursorHeightScale = 1.22;
+ static const double sourceCursorHeightScale = 1.34;
static const double sourceLineNumberScale = 0.92;
static const double hiddenLayoutFontSize = 0.01;
static const double hiddenLayoutHeight = 0.01;
@@ -295,7 +297,7 @@ abstract final class BusyMarkInsets {
vertical: BusyMarkSpacing.sm,
);
static const documentCalloutContent = EdgeInsets.all(BusyMarkSpacing.md);
- static const previewTableCell = EdgeInsets.symmetric(
+ static const documentTableCell = EdgeInsets.symmetric(
horizontal: BusyMarkSpacing.sm,
vertical: BusyMarkSpacing.xs,
);
@@ -306,31 +308,27 @@ abstract final class BusyMarkInsets {
6,
);
static const documentHeadingBlock = EdgeInsets.only(
- top: BusyMarkSizes.previewHeadingTop,
- bottom: BusyMarkSizes.previewHeadingBottom,
+ top: BusyMarkSizes.documentHeadingTop,
+ bottom: BusyMarkSizes.documentHeadingBottom,
);
static const documentParagraphBlock = EdgeInsets.symmetric(
- vertical: BusyMarkSizes.previewHeadingBottom,
+ vertical: BusyMarkSizes.documentHeadingBottom,
+ );
+ static const documentImageBlock = EdgeInsets.symmetric(
+ vertical: BusyMarkSpacing.smPlus,
+ );
+ static const documentThematicBreakBlock = EdgeInsets.symmetric(
+ vertical: BusyMarkSpacing.mdPlus,
);
static const wysiwygContainerBlock = documentCalloutBlock;
static const wysiwygTableBlock = EdgeInsets.symmetric(
vertical: BusyMarkSpacing.smPlus,
);
- static const wysiwygThematicBreakBlock = EdgeInsets.symmetric(
- vertical: BusyMarkSpacing.md,
- );
static const wysiwygDefaultBlock = EdgeInsets.symmetric(
vertical: BusyMarkSpacing.xs,
);
static const wysiwygContainerContent = documentCalloutContent;
static const wysiwygTableContent = EdgeInsets.all(BusyMarkSpacing.smPlus);
- static const wysiwygThematicBreakContent = EdgeInsets.symmetric(
- vertical: BusyMarkSpacing.md,
- );
- static const wysiwygTableCell = EdgeInsets.symmetric(
- horizontal: BusyMarkSpacing.smPlus,
- vertical: BusyMarkSpacing.sm,
- );
static const sourceEditor = EdgeInsets.fromLTRB(
BusyMarkSourceEditorMetrics.paddingLeft,
BusyMarkSourceEditorMetrics.paddingTop,
diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart
index a6909e3..aedccd3 100644
--- a/lib/src/app/busymark_dialogs.dart
+++ b/lib/src/app/busymark_dialogs.dart
@@ -381,6 +381,14 @@ void showBusyMarkKeyboardShortcutsDialog(BuildContext context) {
BusyMarkAppShortcutLabels.save,
),
),
+ BusyMarkActionRow(
+ title: context.l10n.exportAsPdf,
+ subtitle: context.l10n.shortcutExportPdfDescription,
+ leading: const Icon(BusyMarkGlyphs.exportPdf),
+ trailing: const _KeyboardShortcutBadge(
+ BusyMarkAppShortcutLabels.exportPdf,
+ ),
+ ),
BusyMarkActionRow(
title: context.l10n.search,
subtitle: context.l10n.shortcutSearchDescription,
diff --git a/lib/src/app/busymark_glyphs.dart b/lib/src/app/busymark_glyphs.dart
index a854ffd..b2fe106 100644
--- a/lib/src/app/busymark_glyphs.dart
+++ b/lib/src/app/busymark_glyphs.dart
@@ -29,6 +29,7 @@ abstract final class BusyMarkGlyphs {
static const IconData edit = YaruIcons.pen;
static const IconData editorView = YaruIcons.text_editor;
static const IconData error = YaruIcons.error;
+ static const IconData exportPdf = YaruIcons.save_as;
static const IconData externalLink = YaruIcons.external_link;
static const IconData feedback = YaruIcons.chat_text;
static const IconData folder = YaruIcons.folder;
@@ -239,6 +240,9 @@ abstract final class BusyMarkGlyphs {
if (icon == redo) {
return 'edit-redo-symbolic';
}
+ if (icon == exportPdf) {
+ return 'document-save-as-symbolic';
+ }
if (icon == save) {
return 'document-save-symbolic';
}
diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart
index 346ab34..40a0b37 100644
--- a/lib/src/app/busymark_main_menu.dart
+++ b/lib/src/app/busymark_main_menu.dart
@@ -6,6 +6,7 @@ import 'busymark_shortcuts.dart';
import 'localization.dart';
enum BusyMarkMainMenuAction {
+ exportPdf,
settings,
keyboardShortcuts,
markdownAndHtml,
@@ -14,9 +15,14 @@ enum BusyMarkMainMenuAction {
}
class BusyMarkMainMenuButton extends StatelessWidget {
- const BusyMarkMainMenuButton({super.key, required this.onSelected});
+ const BusyMarkMainMenuButton({
+ super.key,
+ required this.onSelected,
+ this.canExportPdf = false,
+ });
final ValueChanged onSelected;
+ final bool canExportPdf;
@override
Widget build(BuildContext context) {
@@ -25,6 +31,13 @@ class BusyMarkMainMenuButton extends StatelessWidget {
tooltip: l10n.mainMenu,
icon: BusyMarkGlyphs.menuVertical,
itemBuilder: (context) => [
+ BusyMarkPopupMenuItem(
+ value: BusyMarkMainMenuAction.exportPdf,
+ label: l10n.exportAsPdf,
+ icon: BusyMarkGlyphs.exportPdf,
+ shortcut: BusyMarkAppShortcutLabels.exportPdf,
+ enabled: canExportPdf,
+ ),
BusyMarkPopupMenuItem(
value: BusyMarkMainMenuAction.settings,
label: l10n.settings,
diff --git a/lib/src/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart
index 4896e2f..045f5e7 100644
--- a/lib/src/app/busymark_shortcuts.dart
+++ b/lib/src/app/busymark_shortcuts.dart
@@ -22,6 +22,7 @@ enum BusyMarkAppShortcutAction {
newDocument,
open,
save,
+ exportPdf,
search,
keyboardShortcuts,
markdownAndHtml,
@@ -39,6 +40,7 @@ abstract final class BusyMarkAppShortcuts {
static const newDocumentLabel = 'Ctrl+N';
static const openLabel = 'Ctrl+O';
static const saveLabel = 'Ctrl+S';
+ static const exportPdfLabel = 'Ctrl+Shift+E';
static const searchLabel = 'Ctrl+F';
static const keyboardShortcutsLabel = 'Ctrl+Alt+K';
static const markdownAndHtmlLabel = 'Ctrl+Alt+M';
@@ -52,6 +54,7 @@ abstract final class BusyMarkAppShortcuts {
static const newDocumentGtkAccelerator = 'n';
static const openGtkAccelerator = 'o';
static const saveGtkAccelerator = 's';
+ static const exportPdfGtkAccelerator = 'e';
static const searchGtkAccelerator = 'f';
static const keyboardShortcutsGtkAccelerator = 'k';
static const markdownAndHtmlGtkAccelerator = 'm';
@@ -77,6 +80,15 @@ abstract final class BusyMarkAppShortcuts {
activator: SingleActivator(LogicalKeyboardKey.keyS, control: true),
gtkAccelerator: saveGtkAccelerator,
);
+ static const exportPdf = BusyMarkShortcutDefinition(
+ label: exportPdfLabel,
+ activator: SingleActivator(
+ LogicalKeyboardKey.keyE,
+ control: true,
+ shift: true,
+ ),
+ gtkAccelerator: exportPdfGtkAccelerator,
+ );
static const search = BusyMarkShortcutDefinition(
label: searchLabel,
activator: SingleActivator(LogicalKeyboardKey.keyF, control: true),
@@ -148,6 +160,7 @@ abstract final class BusyMarkAppShortcuts {
BusyMarkAppShortcutAction.newDocument: newDocument,
BusyMarkAppShortcutAction.open: open,
BusyMarkAppShortcutAction.save: save,
+ BusyMarkAppShortcutAction.exportPdf: exportPdf,
BusyMarkAppShortcutAction.search: search,
BusyMarkAppShortcutAction.keyboardShortcuts: keyboardShortcuts,
BusyMarkAppShortcutAction.markdownAndHtml: markdownAndHtml,
@@ -166,6 +179,7 @@ abstract final class BusyMarkAppShortcutLabels {
static const newDocument = BusyMarkAppShortcuts.newDocumentLabel;
static const open = BusyMarkAppShortcuts.openLabel;
static const save = BusyMarkAppShortcuts.saveLabel;
+ static const exportPdf = BusyMarkAppShortcuts.exportPdfLabel;
static const search = BusyMarkAppShortcuts.searchLabel;
static const keyboardShortcuts = BusyMarkAppShortcuts.keyboardShortcutsLabel;
static const markdownAndHtml = BusyMarkAppShortcuts.markdownAndHtmlLabel;
@@ -183,6 +197,8 @@ abstract final class BusyMarkAppShortcutActivators {
BusyMarkAppShortcuts.newDocument.activator;
static ShortcutActivator get open => BusyMarkAppShortcuts.open.activator;
static ShortcutActivator get save => BusyMarkAppShortcuts.save.activator;
+ static ShortcutActivator get exportPdf =>
+ BusyMarkAppShortcuts.exportPdf.activator;
static ShortcutActivator get search => BusyMarkAppShortcuts.search.activator;
static ShortcutActivator get keyboardShortcuts =>
BusyMarkAppShortcuts.keyboardShortcuts.activator;
@@ -206,6 +222,7 @@ abstract final class BusyMarkAppShortcutGtkAccelerators {
const BusyMarkAppShortcutGtkAccelerators._();
static const search = BusyMarkAppShortcuts.searchGtkAccelerator;
+ static const exportPdf = BusyMarkAppShortcuts.exportPdfGtkAccelerator;
static const keyboardShortcuts =
BusyMarkAppShortcuts.keyboardShortcutsGtkAccelerator;
static const markdownAndHtml =
diff --git a/lib/src/core/atomic_file_writer.dart b/lib/src/core/atomic_file_writer.dart
new file mode 100644
index 0000000..e228c15
--- /dev/null
+++ b/lib/src/core/atomic_file_writer.dart
@@ -0,0 +1,95 @@
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+
+import 'linux_atomic_file_api.dart';
+
+class AtomicFileAlreadyExistsException implements Exception {
+ const AtomicFileAlreadyExistsException(this.path);
+
+ final String path;
+
+ @override
+ String toString() => 'A filesystem entity already exists at "$path".';
+}
+
+/// Publishes generated bytes without ever exposing a partially written file.
+class AtomicFileWriter {
+ const AtomicFileWriter();
+
+ Future writeBytes(
+ String targetPath,
+ List bytes, {
+ required bool overwrite,
+ }) async {
+ if (!Platform.isLinux) {
+ throw UnsupportedError(
+ 'Atomic export is currently supported on Linux only.',
+ );
+ }
+ final absoluteTarget = p.normalize(p.absolute(targetPath));
+ final parent = Directory(p.dirname(absoluteTarget));
+ if (!await parent.exists()) {
+ throw FileSystemException(
+ 'The export destination directory does not exist',
+ parent.path,
+ );
+ }
+
+ final stagingDirectory = await parent.createTemp('.busymark-export-');
+ final stagedFile = File(p.join(stagingDirectory.path, 'document.pdf'));
+ try {
+ await stagedFile.writeAsBytes(bytes, flush: true);
+ if (overwrite) {
+ final targetType = await FileSystemEntity.type(
+ absoluteTarget,
+ followLinks: false,
+ );
+ if (targetType != FileSystemEntityType.notFound &&
+ targetType != FileSystemEntityType.file) {
+ throw FileSystemException(
+ 'The export destination is not a regular file',
+ absoluteTarget,
+ );
+ }
+ await stagedFile.rename(absoluteTarget);
+ return;
+ }
+
+ final errorNumber = LinuxAtomicFileApi.instance.publishNoReplace(
+ stagedFile.absolute.path,
+ absoluteTarget,
+ );
+ if (errorNumber == null) {
+ return;
+ }
+ if (errorNumber == LinuxAtomicFileApi.fileExistsError) {
+ throw AtomicFileAlreadyExistsException(absoluteTarget);
+ }
+ throw FileSystemException(
+ 'Failed to atomically publish the exported PDF',
+ absoluteTarget,
+ OSError('no-replace publication failed', errorNumber),
+ );
+ } finally {
+ await _deleteBestEffort(stagedFile);
+ try {
+ if (await stagingDirectory.exists()) {
+ await stagingDirectory.delete();
+ }
+ } on Object {
+ // Cleanup must not hide the write result.
+ }
+ }
+ }
+
+ Future _deleteBestEffort(File file) async {
+ try {
+ if (await file.exists()) {
+ await file.delete();
+ }
+ } on Object {
+ // Cleanup must not hide the write result.
+ }
+ }
+}
diff --git a/lib/src/editor/document_callout.dart b/lib/src/editor/document_callout.dart
index 4c67686..e50c462 100644
--- a/lib/src/editor/document_callout.dart
+++ b/lib/src/editor/document_callout.dart
@@ -1,8 +1,45 @@
import 'package:flutter/material.dart';
import '../app/busymark_design.dart';
+import '../app/busymark_glyphs.dart';
import 'document_surface.dart';
+/// Shared Writerside admonition presentation for Editor and Preview.
+class BusyMarkDocumentAdmonition extends StatelessWidget {
+ const BusyMarkDocumentAdmonition({
+ super.key,
+ required this.style,
+ required this.child,
+ this.margin = BusyMarkInsets.documentCalloutBlock,
+ this.onTap,
+ });
+
+ final String? style;
+ final Widget child;
+ final EdgeInsetsGeometry margin;
+ final VoidCallback? onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = BusyMarkSurfaceColors.of(context);
+ return BusyMarkDocumentCallout(
+ icon: switch (style) {
+ 'warning' => BusyMarkGlyphs.warning,
+ 'tip' => BusyMarkGlyphs.tip,
+ _ => BusyMarkGlyphs.info,
+ },
+ backgroundColor: switch (style) {
+ 'warning' => colors.admonitionWarning,
+ 'tip' => colors.admonitionTip,
+ _ => colors.admonitionNote,
+ },
+ margin: margin,
+ onTap: onTap,
+ child: child,
+ );
+ }
+}
+
/// Shared document surface for quotes, admonitions, and similar callouts.
///
/// The component owns its visual geometry so editable and rendered document
diff --git a/lib/src/editor/document_list_marker.dart b/lib/src/editor/document_list_marker.dart
new file mode 100644
index 0000000..861d21b
--- /dev/null
+++ b/lib/src/editor/document_list_marker.dart
@@ -0,0 +1,74 @@
+import 'package:flutter/material.dart';
+
+import '../app/busymark_design.dart';
+import '../app/busymark_glyphs.dart';
+
+/// Shared list marker geometry for Editor and Preview.
+class BusyMarkDocumentListMarker extends StatelessWidget {
+ const BusyMarkDocumentListMarker({
+ super.key,
+ this.ordered = false,
+ this.marker,
+ this.task,
+ });
+
+ final bool ordered;
+ final String? marker;
+ final bool? task;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = BusyMarkSurfaceColors.of(context);
+ final taskState = task;
+ final markerWidget = taskState != null
+ ? Icon(
+ taskState ? BusyMarkGlyphs.checkedBox : BusyMarkGlyphs.task,
+ size: BusyMarkSizes.iconSm,
+ color: colors.mutedForeground,
+ )
+ : ordered
+ ? Text(
+ marker ?? '1.',
+ textAlign: TextAlign.end,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: colors.mutedForeground,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ ),
+ )
+ : Padding(
+ padding: const EdgeInsets.only(
+ top: BusyMarkSizes.listMarkerTopInset,
+ ),
+ child: SizedBox.square(
+ dimension: BusyMarkSizes.markerDot,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: colors.mutedForeground,
+ shape: BoxShape.circle,
+ ),
+ ),
+ ),
+ );
+ return SizedBox(
+ width: BusyMarkSizes.documentListMarkerWidth,
+ child: Padding(
+ padding: const EdgeInsets.only(
+ top: BusyMarkSizes.documentListMarkerTopInset,
+ ),
+ child: markerWidget,
+ ),
+ );
+ }
+}
+
+EdgeInsets busyMarkDocumentListItemPadding({
+ required bool listRunEnd,
+ required bool endsWithNestedList,
+}) {
+ return EdgeInsets.only(
+ top: BusyMarkSpacing.xs,
+ bottom: listRunEnd && !endsWithNestedList
+ ? BusyMarkSpacing.md
+ : BusyMarkSpacing.xs,
+ );
+}
diff --git a/lib/src/editor/document_surface.dart b/lib/src/editor/document_surface.dart
index 92354d8..2e45cc2 100644
--- a/lib/src/editor/document_surface.dart
+++ b/lib/src/editor/document_surface.dart
@@ -10,6 +10,20 @@ TextStyle busyMarkDocumentBodyTextStyle(BuildContext context, {Color? color}) {
);
}
+/// Shared heading typography for editable and rendered document views.
+TextStyle busyMarkDocumentHeadingTextStyle(BuildContext context, int? level) {
+ final theme = Theme.of(context).textTheme;
+ final style = switch (level ?? 6) {
+ 1 => theme.headlineSmall,
+ 2 => theme.titleLarge,
+ 3 => theme.titleMedium,
+ 4 => theme.titleSmall,
+ 5 => theme.bodyLarge,
+ _ => theme.bodyMedium,
+ };
+ return (style ?? const TextStyle()).copyWith(fontWeight: FontWeight.w700);
+}
+
/// Resolves the actual child inset of [BusyMarkDocumentSurface].
///
/// Flutter includes a decorated container's border dimensions in addition to
diff --git a/lib/src/editor/document_thematic_break.dart b/lib/src/editor/document_thematic_break.dart
new file mode 100644
index 0000000..b59f88d
--- /dev/null
+++ b/lib/src/editor/document_thematic_break.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+
+import '../app/busymark_design.dart';
+
+/// Shared thematic-break geometry for Editor and Preview.
+///
+/// The Editor handle is painted over the line so its editing affordance does
+/// not change the document layout.
+class BusyMarkDocumentThematicBreak extends StatelessWidget {
+ const BusyMarkDocumentThematicBreak({
+ super.key,
+ this.editable = false,
+ this.selected = false,
+ });
+
+ final bool editable;
+ final bool selected;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = BusyMarkSurfaceColors.of(context);
+ final scheme = Theme.of(context).colorScheme;
+ final lineColor = selected
+ ? scheme.primary.withValues(alpha: BusyMarkAlpha.thematicBreakSelected)
+ : colors.mutedForeground.withValues(alpha: BusyMarkAlpha.thematicBreak);
+ final handleColor = selected
+ ? scheme.primary
+ : colors.mutedForeground.withValues(
+ alpha: BusyMarkAlpha.thematicBreakHandle,
+ );
+ return Padding(
+ padding: BusyMarkInsets.documentThematicBreakBlock,
+ child: SizedBox(
+ height: BusyMarkStroke.thematicBreak,
+ child: Stack(
+ clipBehavior: Clip.none,
+ alignment: Alignment.center,
+ children: [
+ Positioned.fill(
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: lineColor,
+ borderRadius: BorderRadius.circular(BusyMarkRadius.pill),
+ ),
+ ),
+ ),
+ if (editable)
+ SizedBox(
+ width: BusyMarkSizes.thematicBreakHandleWidth,
+ height: BusyMarkSizes.markerDot,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: handleColor,
+ borderRadius: BorderRadius.circular(BusyMarkRadius.pill),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/editor/markdown_image_view.dart b/lib/src/editor/markdown_image_view.dart
index 5535350..cf953ea 100644
--- a/lib/src/editor/markdown_image_view.dart
+++ b/lib/src/editor/markdown_image_view.dart
@@ -13,6 +13,24 @@ import '../app/busymark_glyphs.dart';
import '../app/localization.dart';
import '../core/local_image_resolver.dart';
+/// Resolves an authored image width using the shared document-view bounds.
+double? busyMarkDocumentImageWidth(Map attributes) {
+ final value = attributes['width'];
+ if (value == null) {
+ return null;
+ }
+ final parsed = double.tryParse(value.replaceAll(RegExp('[^0-9.]'), ''));
+ if (parsed == null || parsed <= 0) {
+ return null;
+ }
+ return parsed
+ .clamp(
+ BusyMarkSizes.documentImageMinWidth,
+ BusyMarkSizes.documentImageMaxWidth,
+ )
+ .toDouble();
+}
+
class MarkdownImageView extends StatelessWidget {
const MarkdownImageView({
super.key,
@@ -26,7 +44,7 @@ class MarkdownImageView extends StatelessWidget {
this.onRemoteImageBlocked,
this.width,
this.height,
- this.maxWidth = 760,
+ this.maxWidth = BusyMarkSizes.documentImageMaxWidth,
this.maxHeight,
});
diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart
index a6e0090..5e50f4b 100644
--- a/lib/src/editor/source/source_editor.dart
+++ b/lib/src/editor/source/source_editor.dart
@@ -252,45 +252,48 @@ class BusyMarkSourceEditorState extends State {
},
),
},
- child: TextField(
- controller: _controller,
- undoController: _undoController,
- focusNode: _focusNode,
- scrollController: _scrollController,
- textDirection: TextDirection.ltr,
- keyboardType: widget.wordWrap
- ? TextInputType.multiline
- : TextInputType.text,
- autocorrect: false,
- enableSuggestions: false,
- smartDashesType: SmartDashesType.disabled,
- smartQuotesType: SmartQuotesType.disabled,
- maxLines: null,
- expands: true,
- textAlignVertical: TextAlignVertical.top,
- style: _sourceTextStyle,
- strutStyle: sourceStrutStyle,
- selectionHeightStyle: BoxHeightStyle.max,
- selectionWidthStyle: BoxWidthStyle.tight,
- cursorColor: colors.foreground.withValues(
- alpha: BusyMarkAlpha.sourceCursor,
+ child: DefaultTextHeightBehavior(
+ textHeightBehavior: sourceTextHeightBehavior,
+ child: TextField(
+ controller: _controller,
+ undoController: _undoController,
+ focusNode: _focusNode,
+ scrollController: _scrollController,
+ textDirection: TextDirection.ltr,
+ keyboardType: widget.wordWrap
+ ? TextInputType.multiline
+ : TextInputType.text,
+ autocorrect: false,
+ enableSuggestions: false,
+ smartDashesType: SmartDashesType.disabled,
+ smartQuotesType: SmartQuotesType.disabled,
+ maxLines: null,
+ expands: true,
+ textAlignVertical: TextAlignVertical.top,
+ style: _sourceTextStyle,
+ strutStyle: sourceStrutStyle,
+ selectionHeightStyle: BoxHeightStyle.strut,
+ selectionWidthStyle: BoxWidthStyle.tight,
+ cursorColor: colors.foreground.withValues(
+ alpha: BusyMarkAlpha.sourceCursor,
+ ),
+ cursorHeight:
+ widget.editorFontSize *
+ BusyMarkTypography.sourceCursorHeightScale,
+ cursorWidth: BusyMarkStroke.sourceCursor,
+ decoration: const InputDecoration(
+ isCollapsed: true,
+ filled: false,
+ fillColor: BusyMarkLinuxPalette.transparent,
+ hoverColor: BusyMarkLinuxPalette.transparent,
+ focusColor: BusyMarkLinuxPalette.transparent,
+ border: InputBorder.none,
+ enabledBorder: InputBorder.none,
+ focusedBorder: InputBorder.none,
+ contentPadding: BusyMarkInsets.sourceEditor,
+ ),
+ onChanged: (_) => _handleSourceChanged(),
),
- cursorHeight:
- widget.editorFontSize *
- BusyMarkTypography.sourceCursorHeightScale,
- cursorWidth: BusyMarkStroke.sourceCursor,
- decoration: const InputDecoration(
- isCollapsed: true,
- filled: false,
- fillColor: BusyMarkLinuxPalette.transparent,
- hoverColor: BusyMarkLinuxPalette.transparent,
- focusColor: BusyMarkLinuxPalette.transparent,
- border: InputBorder.none,
- enabledBorder: InputBorder.none,
- focusedBorder: InputBorder.none,
- contentPadding: BusyMarkInsets.sourceEditor,
- ),
- onChanged: (_) => _handleSourceChanged(),
),
),
),
@@ -718,7 +721,7 @@ class BusyMarkSourceEditorState extends State {
fontFamily: BusyMarkTypography.monoFontFamily,
fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback,
fontSize: widget.editorFontSize,
- height: BusyMarkTypography.codeLineHeight,
+ height: BusyMarkTypography.sourceEditorLineHeight,
leadingDistribution: TextLeadingDistribution.even,
);
diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart
index 4753d07..6561542 100644
--- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart
+++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart
@@ -5,8 +5,10 @@ import '../../app/busymark_glyphs.dart';
import '../../app/localization.dart';
import '../document_callout.dart';
import '../document_code_block.dart';
+import '../document_list_marker.dart';
import '../document_surface.dart';
import '../document_text_direction.dart';
+import '../document_thematic_break.dart';
import '../markdown_image_view.dart';
import '../../markdown/busymark_document.dart';
import 'wysiwyg_inline_controller.dart';
@@ -41,7 +43,11 @@ bool _isTechnicalWysiwygBlock(BusyBlock block) {
};
}
-EdgeInsets busyMarkWysiwygOuterPadding(BusyBlock block, {bool first = false}) {
+EdgeInsets busyMarkWysiwygOuterPadding(
+ BusyBlock block, {
+ bool first = false,
+ bool listRunEnd = false,
+}) {
final padding = switch (block.kind) {
BusyBlockKind.heading => BusyMarkInsets.documentHeadingBlock,
BusyBlockKind.paragraph => BusyMarkInsets.documentParagraphBlock,
@@ -54,7 +60,14 @@ EdgeInsets busyMarkWysiwygOuterPadding(BusyBlock block, {bool first = false}) {
BusyBlockKind.htmlBlock ||
BusyBlockKind.unknown => BusyMarkInsets.wysiwygContainerBlock,
BusyBlockKind.table => BusyMarkInsets.wysiwygTableBlock,
- BusyBlockKind.thematicBreak => BusyMarkInsets.wysiwygThematicBreakBlock,
+ BusyBlockKind.image => BusyMarkInsets.documentImageBlock,
+ BusyBlockKind.thematicBreak => EdgeInsets.zero,
+ BusyBlockKind.unorderedListItem ||
+ BusyBlockKind.orderedListItem ||
+ BusyBlockKind.taskListItem => busyMarkDocumentListItemPadding(
+ listRunEnd: listRunEnd,
+ endsWithNestedList: _endsWithNestedList(block),
+ ),
_ => BusyMarkInsets.wysiwygDefaultBlock,
};
final trimFirstBlockSpacing =
@@ -75,7 +88,6 @@ EdgeInsets busyMarkWysiwygContentPadding(BusyBlock block) {
BusyBlockKind.htmlBlock ||
BusyBlockKind.unknown => BusyMarkInsets.wysiwygContainerContent,
BusyBlockKind.table => BusyMarkInsets.wysiwygTableContent,
- BusyBlockKind.thematicBreak => BusyMarkInsets.wysiwygThematicBreakContent,
_ => EdgeInsets.zero,
};
}
@@ -83,29 +95,43 @@ EdgeInsets busyMarkWysiwygContentPadding(BusyBlock block) {
EdgeInsets busyMarkWysiwygTextLayoutInsets(BusyBlock block) {
final contentPadding = busyMarkWysiwygContentPadding(block);
return switch (block.kind) {
- BusyBlockKind.codeBlock || BusyBlockKind.blockquote =>
- busyMarkDocumentSurfaceLayoutInsets(contentPadding),
+ BusyBlockKind.codeBlock ||
+ BusyBlockKind.blockquote ||
+ BusyBlockKind.writersideAdmonition => busyMarkDocumentSurfaceLayoutInsets(
+ contentPadding,
+ ),
_ => contentPadding,
};
}
-bool busyMarkWysiwygHasPrefix(BusyBlock block) {
+double busyMarkWysiwygPrefixExtent(BusyBlock block) {
return switch (block.kind) {
BusyBlockKind.unorderedListItem ||
BusyBlockKind.orderedListItem ||
- BusyBlockKind.taskListItem ||
- BusyBlockKind.blockquote ||
- BusyBlockKind.writersideAdmonition ||
- BusyBlockKind.htmlBlock => true,
- _ => false,
+ BusyBlockKind.taskListItem => BusyMarkSizes.documentListIndent,
+ BusyBlockKind.htmlBlock =>
+ BusyMarkSizes.wysiwygPrefixWidth + BusyMarkSpacing.sm,
+ _ => 0,
};
}
+bool _isWysiwygListItem(BusyBlock block) => switch (block.kind) {
+ BusyBlockKind.unorderedListItem ||
+ BusyBlockKind.orderedListItem ||
+ BusyBlockKind.taskListItem => true,
+ _ => false,
+};
+
+bool _endsWithNestedList(BusyBlock block) {
+ return block.children.isNotEmpty && _isWysiwygListItem(block.children.last);
+}
+
class BusyMarkWysiwygBlockField extends StatelessWidget {
const BusyMarkWysiwygBlockField({
super.key,
required this.block,
this.first = false,
+ this.listRunEnd = false,
required this.documentFilePath,
this.workspaceRoot,
this.writersideRoot,
@@ -134,6 +160,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget {
final BusyBlock block;
final bool first;
+ final bool listRunEnd;
final String documentFilePath;
final String? workspaceRoot;
final String? writersideRoot;
@@ -207,6 +234,19 @@ class BusyMarkWysiwygBlockField extends StatelessWidget {
child: content,
),
)
+ : block.kind == BusyBlockKind.writersideAdmonition
+ ? Directionality(
+ textDirection: busyMarkWysiwygBlockTextDirection(
+ block,
+ fallback: Directionality.of(context),
+ ),
+ child: BusyMarkDocumentAdmonition(
+ style: block.attributes['element'] ?? block.attributes['style'],
+ margin: _padding,
+ onTap: tapHandler,
+ child: content,
+ ),
+ )
: Padding(
padding: _padding,
child: GestureDetector(
@@ -240,7 +280,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget {
fallback: Directionality.of(context),
);
if (block.kind == BusyBlockKind.thematicBreak) {
- return _ThematicBreakBlockView(selected: selected);
+ return BusyMarkDocumentThematicBreak(editable: true, selected: selected);
}
if (block.kind == BusyBlockKind.table) {
return Directionality(
@@ -275,7 +315,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (prefix != null) ...[
- SizedBox(width: BusyMarkSizes.wysiwygPrefixWidth, child: prefix),
+ prefix,
const SizedBox(width: BusyMarkSpacing.sm),
],
Expanded(
@@ -398,15 +438,13 @@ class BusyMarkWysiwygBlockField extends StatelessWidget {
14;
return switch (block.kind) {
BusyBlockKind.blockquote ||
- BusyBlockKind.writersideAdmonition ||
BusyBlockKind.writersideTabs ||
BusyBlockKind.writersideProcedure ||
BusyBlockKind.writersideRawXml ||
BusyBlockKind.htmlBlock ||
BusyBlockKind.unknown => fontSize * 2.4,
BusyBlockKind.table => fontSize * 5.8,
- BusyBlockKind.thematicBreak => fontSize * 2.2,
- BusyBlockKind.image => BusyMarkSizes.iconButton,
+ BusyBlockKind.image => BusyMarkSizes.documentImageMinHeight,
_ => 0,
};
}
@@ -419,32 +457,15 @@ class BusyMarkWysiwygBlockField extends StatelessWidget {
return block.rawSource ?? block.plainText;
}
- EdgeInsets get _padding => busyMarkWysiwygOuterPadding(block, first: first);
+ EdgeInsets get _padding =>
+ busyMarkWysiwygOuterPadding(block, first: first, listRunEnd: listRunEnd);
EdgeInsets get _contentPadding => busyMarkWysiwygContentPadding(block);
TextStyle _textStyle(BuildContext context) {
- final theme = Theme.of(context).textTheme;
final level = int.tryParse(block.attributes['level'] ?? '') ?? 0;
return switch (block.kind) {
- BusyBlockKind.heading when level == 1 => theme.headlineSmall!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading when level == 2 => theme.titleLarge!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading when level == 3 => theme.titleMedium!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading when level == 4 => theme.titleSmall!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading when level == 5 => theme.bodyLarge!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading => theme.bodyMedium!.copyWith(
- fontWeight: FontWeight.w700,
- ),
+ BusyBlockKind.heading => busyMarkDocumentHeadingTextStyle(context, level),
BusyBlockKind.codeBlock => busyMarkDocumentCodeTextStyle(context),
_ => busyMarkDocumentBodyTextStyle(context),
};
@@ -452,45 +473,22 @@ class BusyMarkWysiwygBlockField extends StatelessWidget {
Widget? _prefix(BuildContext context) {
final colors = BusyMarkSurfaceColors.of(context);
- final markerStyle = _textStyle(context).copyWith(
- color: colors.mutedForeground,
- fontWeight: FontWeight.w500,
- fontFeatures: const [FontFeature.tabularFigures()],
- );
return switch (block.kind) {
- BusyBlockKind.unorderedListItem => Padding(
- padding: const EdgeInsets.only(top: BusyMarkSpacing.sm),
- child: SizedBox.square(
- dimension: BusyMarkSizes.markerDot,
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: colors.mutedForeground,
- shape: BoxShape.circle,
- ),
- ),
- ),
- ),
- BusyBlockKind.orderedListItem => Text(
- block.attributes['marker'] ?? '1.',
- textAlign: TextAlign.end,
- style: markerStyle,
+ BusyBlockKind.unorderedListItem => const BusyMarkDocumentListMarker(),
+ BusyBlockKind.orderedListItem => BusyMarkDocumentListMarker(
+ ordered: true,
+ marker: block.attributes['marker'],
),
- BusyBlockKind.taskListItem => Icon(
- block.attributes['task'] == 'true'
- ? BusyMarkGlyphs.checkedBox
- : BusyMarkGlyphs.task,
- size: BusyMarkSizes.iconSm,
- color: colors.mutedForeground,
+ BusyBlockKind.taskListItem => BusyMarkDocumentListMarker(
+ task: block.attributes['task'] == 'true',
),
- BusyBlockKind.writersideAdmonition => Icon(
- BusyMarkGlyphs.info,
- size: BusyMarkSizes.iconSm,
- color: colors.mutedForeground,
- ),
- BusyBlockKind.htmlBlock => Icon(
- BusyMarkGlyphs.code,
- size: BusyMarkSizes.iconSm,
- color: colors.mutedForeground,
+ BusyBlockKind.htmlBlock => SizedBox(
+ width: BusyMarkSizes.wysiwygPrefixWidth,
+ child: Icon(
+ BusyMarkGlyphs.code,
+ size: BusyMarkSizes.iconSm,
+ color: colors.mutedForeground,
+ ),
),
_ => null,
};
@@ -690,7 +688,10 @@ class _RenderedHtmlBlock extends StatelessWidget {
),
child: _RenderedHtmlInlineText(
block: block,
- style: _headingStyle(context, block),
+ style: busyMarkDocumentHeadingTextStyle(
+ context,
+ int.tryParse(block.attributes['level'] ?? ''),
+ ),
),
),
BusyBlockKind.paragraph => Padding(
@@ -768,13 +769,7 @@ class _RenderedHtmlBlock extends StatelessWidget {
onRemoteImageBlocked: onRemoteImageBlocked,
first: first,
),
- BusyBlockKind.thematicBreak => Padding(
- padding: EdgeInsets.only(
- top: first ? 0 : BusyMarkSpacing.sm,
- bottom: BusyMarkSpacing.sm,
- ),
- child: Divider(height: BusyMarkStroke.thematicBreak),
- ),
+ BusyBlockKind.thematicBreak => const BusyMarkDocumentThematicBreak(),
BusyBlockKind.htmlBlock when block.attributes['htmlTag'] == 'figure' =>
_RenderedHtmlFigure(
block: block,
@@ -812,19 +807,6 @@ class _RenderedHtmlBlock extends StatelessWidget {
? content
: Directionality(textDirection: blockDirection, child: content);
}
-
- TextStyle? _headingStyle(BuildContext context, BusyBlock block) {
- final level = int.tryParse(block.attributes['level'] ?? '') ?? 0;
- final theme = Theme.of(context).textTheme;
- return switch (level) {
- 1 => theme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
- 2 => theme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
- 3 => theme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
- 4 => theme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
- 5 => theme.bodyLarge?.copyWith(fontWeight: FontWeight.w700),
- _ => theme.bodyMedium?.copyWith(fontWeight: FontWeight.w700),
- };
- }
}
class _RenderedHtmlFigure extends StatelessWidget {
@@ -921,10 +903,6 @@ class _RenderedHtmlListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final colors = BusyMarkSurfaceColors.of(context);
- final marker = block.kind == BusyBlockKind.orderedListItem
- ? block.attributes['marker'] ?? '1.'
- : '•';
return Padding(
padding: EdgeInsets.only(
top: first ? 0 : BusyMarkSpacing.xs,
@@ -936,16 +914,12 @@ class _RenderedHtmlListItem extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- SizedBox(
- width: BusyMarkSizes.previewListMarkerWidth,
- child: Text(
- marker,
- textAlign: TextAlign.end,
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: colors.mutedForeground,
- fontFeatures: const [FontFeature.tabularFigures()],
- ),
- ),
+ BusyMarkDocumentListMarker(
+ ordered: block.kind == BusyBlockKind.orderedListItem,
+ marker: block.attributes['marker'],
+ task: block.kind == BusyBlockKind.taskListItem
+ ? block.attributes['task'] == 'true'
+ : null,
),
const SizedBox(width: BusyMarkSpacing.sm),
Expanded(child: _RenderedHtmlInlineText(block: block)),
@@ -954,8 +928,7 @@ class _RenderedHtmlListItem extends StatelessWidget {
if (block.children.isNotEmpty)
Padding(
padding: const EdgeInsetsDirectional.only(
- start:
- BusyMarkSizes.previewListMarkerWidth + BusyMarkSpacing.sm,
+ start: BusyMarkSizes.documentListIndent,
),
child: _RenderedHtmlBlocks(
blocks: block.children,
@@ -1049,12 +1022,11 @@ class _RenderedHtmlTableCell extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final style = Theme.of(context).textTheme.bodyMedium?.copyWith(
- fontWeight: header ? FontWeight.w700 : FontWeight.w400,
- height: BusyMarkTypography.bodyLineHeight,
- );
+ final style = busyMarkDocumentBodyTextStyle(
+ context,
+ ).copyWith(fontWeight: header ? FontWeight.w700 : FontWeight.w400);
return Padding(
- padding: BusyMarkInsets.wysiwygTableCell,
+ padding: BusyMarkInsets.documentTableCell,
child: cell == null
? const SizedBox.shrink()
: _RenderedHtmlInlineText(block: cell!, style: style),
@@ -1180,48 +1152,6 @@ String _directionalText(BusyBlock block) {
].join(' ');
}
-class _ThematicBreakBlockView extends StatelessWidget {
- const _ThematicBreakBlockView({required this.selected});
-
- final bool selected;
-
- @override
- Widget build(BuildContext context) {
- final colors = BusyMarkSurfaceColors.of(context);
- final scheme = Theme.of(context).colorScheme;
- final lineColor = selected
- ? scheme.primary.withValues(alpha: BusyMarkAlpha.thematicBreakSelected)
- : colors.mutedForeground.withValues(alpha: BusyMarkAlpha.thematicBreak);
- final accentColor = selected
- ? scheme.primary
- : colors.mutedForeground.withValues(
- alpha: BusyMarkAlpha.thematicBreakHandle,
- );
- return Center(
- child: Stack(
- alignment: Alignment.center,
- children: [
- Container(
- height: BusyMarkStroke.thematicBreak,
- decoration: BoxDecoration(
- color: lineColor,
- borderRadius: BorderRadius.circular(BusyMarkRadius.pill),
- ),
- ),
- Container(
- width: BusyMarkSizes.thematicBreakHandleWidth,
- height: BusyMarkSizes.markerDot,
- decoration: BoxDecoration(
- color: accentColor,
- borderRadius: BorderRadius.circular(BusyMarkRadius.pill),
- ),
- ),
- ],
- ),
- );
- }
-}
-
class _TableBlockEditor extends StatelessWidget {
const _TableBlockEditor({
required this.block,
@@ -1248,7 +1178,6 @@ class _TableBlockEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = BusyMarkSurfaceColors.of(context);
- final theme = Theme.of(context).textTheme;
final rows = block.children;
final columnCount = _columnCount(rows);
final dataWidth = (columnCount * BusyMarkSizes.tableColumnBaseWidth)
@@ -1303,7 +1232,7 @@ class _TableBlockEditor extends StatelessWidget {
TableRow(
decoration: BoxDecoration(
color: _isHeaderRow(row, rowIndex)
- ? colors.controlHover
+ ? colors.control
: BusyMarkLinuxPalette.transparent,
),
children: [
@@ -1318,9 +1247,7 @@ class _TableBlockEditor extends StatelessWidget {
? row.children[column]
: null,
header: _isHeaderRow(row, rowIndex),
- style: theme.bodyMedium!.copyWith(
- height: BusyMarkTypography.codeLineHeight,
- ),
+ style: busyMarkDocumentBodyTextStyle(context),
onFocused: onFocused,
onChanged: onCellChanged,
),
@@ -1533,7 +1460,7 @@ class _TableCellEditorState extends State<_TableCellEditor> {
return const SizedBox.shrink();
}
return Padding(
- padding: BusyMarkInsets.wysiwygTableCell,
+ padding: BusyMarkInsets.documentTableCell,
child: TextField(
key: ValueKey(cell.id),
controller: _controller,
@@ -1660,6 +1587,7 @@ class _ImageBlockEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
final source = _imageSource(block);
+ final width = busyMarkDocumentImageWidth(block.attributes);
return Align(
alignment: AlignmentDirectional.centerStart,
child: MarkdownImageView(
@@ -1671,6 +1599,8 @@ class _ImageBlockEditor extends StatelessWidget {
imagesDir: imagesDir,
allowRemoteImages: allowRemoteImages,
onRemoteImageBlocked: onRemoteImageBlocked,
+ width: width,
+ maxWidth: width ?? BusyMarkSizes.documentImageMaxWidth,
),
);
}
diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart
index a16002e..4264882 100644
--- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart
+++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import '../../core/path_utils.dart';
+import '../../core/source_span.dart';
import '../../markdown/busymark_document.dart';
import '../../markdown/busymark_markdown_serializer.dart';
import '../../markdown/raw_html_adapter.dart';
@@ -133,18 +134,24 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
);
if (index == 0) {
replacements.add(
- block.copyWith(inlines: inlines, preserveRaw: false, dirty: true),
+ block.copyWith(
+ inlines: inlines,
+ attributes: _attributesForText(block.attributes, block.kind, part),
+ preserveRaw: false,
+ dirty: true,
+ ),
);
} else {
+ final splitKind = _splitKindFor(block.kind);
replacements.add(
BusyBlock(
id: _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)),
- kind: _splitKindFor(block.kind),
+ kind: splitKind,
inlines: inlines,
- attributes: _splitAttributesFor(
- block,
- _splitKindFor(block.kind),
- orderedOffset: index,
+ attributes: _attributesForText(
+ _splitAttributesFor(block, splitKind, orderedOffset: index),
+ splitKind,
+ part,
),
dirty: true,
),
@@ -691,10 +698,10 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
notifyListeners();
}
- void indentListItems(Iterable blockIds) {
+ bool indentListItems(Iterable blockIds) {
final ids = blockIds.toSet();
if (ids.isEmpty) {
- return;
+ return false;
}
var changed = false;
List visit(List blocks) {
@@ -727,16 +734,17 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
final blocks = visit(_document.blocks);
if (!changed) {
- return;
+ return false;
}
_document = _document.copyWith(blocks: blocks);
notifyListeners();
+ return true;
}
- void outdentListItems(Iterable blockIds) {
+ bool outdentListItems(Iterable blockIds) {
final ids = blockIds.toSet();
if (ids.isEmpty) {
- return;
+ return false;
}
var changed = false;
@@ -778,10 +786,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
result.addAll(childResult.outdented);
}
if (!changed) {
- return;
+ return false;
}
_document = _document.copyWith(blocks: result);
notifyListeners();
+ return true;
}
String? splitBlockAt(String blockId, int offset) {
@@ -804,7 +813,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
_styleRangesForSlice(ranges, 0, safeOffset),
),
children: block.children,
- attributes: block.attributes,
+ attributes: _attributesForText(block.attributes, block.kind, leftText),
dirty: true,
);
final nextBlock = BusyBlock(
@@ -814,7 +823,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
rightText,
_styleRangesForSlice(ranges, safeOffset, text.length),
),
- attributes: _splitAttributesFor(block, nextKind, orderedOffset: 1),
+ attributes: _attributesForText(
+ _splitAttributesFor(block, nextKind, orderedOffset: 1),
+ nextKind,
+ rightText,
+ ),
dirty: true,
);
_document = _document.copyWith(
@@ -857,8 +870,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
final previousText = previous.plainText;
final currentText = current.plainText;
if (currentText.isEmpty) {
+ final updatedPrevious = _withoutSourceSpan(previous, dirty: true);
_document = _document.copyWith(
- blocks: [...blocks.take(index), ...blocks.skip(index + 1)],
+ blocks: [
+ ...blocks.take(index - 1),
+ updatedPrevious,
+ ...blocks.skip(index + 1),
+ ],
);
notifyListeners();
return BusyWysiwygTextSplitResult(
@@ -1147,6 +1165,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
id: block.id,
kind: BusyBlockKind.paragraph,
inlines: _textInlines(''),
+ attributes: const {busyMarkPreserveEmptyParagraphAttribute: 'true'},
dirty: true,
),
);
@@ -1320,14 +1339,12 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier {
List? ranges,
}) {
final kind = styled.kind;
+ final blockText = text ?? styled.text;
return BusyBlock(
id: _nextGeneratedBlockId(_newBlockPrefixFor(kind)),
kind: kind,
- inlines: _inlinesFromStyleRanges(
- text ?? styled.text,
- ranges ?? styled.ranges,
- ),
- attributes: styled.attributes,
+ inlines: _inlinesFromStyleRanges(blockText, ranges ?? styled.ranges),
+ attributes: _attributesForText(styled.attributes, kind, blockText),
preserveRaw: false,
dirty: true,
);
@@ -1485,6 +1502,19 @@ Map _splitAttributesFor(
return attributes;
}
+Map _attributesForText(
+ Map attributes,
+ BusyBlockKind kind,
+ String text,
+) {
+ final updated = {...attributes}
+ ..remove(busyMarkPreserveEmptyParagraphAttribute);
+ if (kind == BusyBlockKind.paragraph && text.isEmpty) {
+ updated[busyMarkPreserveEmptyParagraphAttribute] = 'true';
+ }
+ return updated;
+}
+
bool _shouldSplitNewlines(BusyBlockKind kind) {
return switch (kind) {
BusyBlockKind.paragraph ||
@@ -1557,11 +1587,28 @@ BusyBlock _blockWithEditedText(
}
return block.copyWith(
inlines: _inlinesFromStyleRanges(nextText, nextRanges),
+ attributes: _attributesForText(block.attributes, block.kind, nextText),
preserveRaw: false,
dirty: true,
);
}
+BusyBlock _withoutSourceSpan(BusyBlock block, {required bool dirty}) {
+ return BusyBlock(
+ id: block.id,
+ kind: block.kind,
+ inlines: block.inlines,
+ children: block.children,
+ attributes: block.attributes,
+ rawSource: block.rawSource,
+ preserveRaw: block.preserveRaw,
+ isSourceOnly: block.isSourceOnly,
+ isGenerated: block.isGenerated,
+ isSourceProtected: block.isSourceProtected,
+ dirty: dirty,
+ );
+}
+
BusyBlock _blockWithCommand(
BusyBlock block,
BusyWysiwygBlockCommand command, {
@@ -1571,7 +1618,8 @@ BusyBlock _blockWithCommand(
final attributes = {...block.attributes}
..remove('ordered')
..remove('marker')
- ..remove('task');
+ ..remove('task')
+ ..remove(busyMarkPreserveEmptyParagraphAttribute);
if (command == BusyWysiwygBlockCommand.heading1) {
attributes['level'] = '1';
} else if (command == BusyWysiwygBlockCommand.heading2) {
@@ -1779,18 +1827,19 @@ String _incrementOrderedMarker(String? marker, int offset) {
}
BusyDocument _ensureEditableDocument(BusyDocument document) {
- final hasEditableBlock = document.blocks.any(
+ final withBlankParagraphs = _restoreSourceBlankParagraphs(document);
+ final hasEditableBlock = withBlankParagraphs.blocks.any(
(block) =>
block.kind != BusyBlockKind.frontMatter &&
!block.isSourceOnly &&
!block.isSourceProtected,
);
if (hasEditableBlock) {
- return document;
+ return withBlankParagraphs;
}
- return document.copyWith(
+ return withBlankParagraphs.copyWith(
blocks: [
- ...document.blocks,
+ ...withBlankParagraphs.blocks,
const BusyBlock(
id: 'empty-paragraph',
kind: BusyBlockKind.paragraph,
@@ -1800,6 +1849,133 @@ BusyDocument _ensureEditableDocument(BusyDocument document) {
);
}
+BusyDocument _restoreSourceBlankParagraphs(BusyDocument document) {
+ final source = document.source;
+ if (source == null ||
+ document.blocks.any(
+ (block) =>
+ block.kind == BusyBlockKind.paragraph && block.plainText.isEmpty,
+ )) {
+ return document;
+ }
+ final frontMatterBlocks = document.blocks
+ .where((block) => block.kind == BusyBlockKind.frontMatter)
+ .toList();
+ final sourceBlocks = document.blocks
+ .where(
+ (block) =>
+ block.kind != BusyBlockKind.frontMatter && !block.isGenerated,
+ )
+ .toList();
+ final generatedBlocks = document.blocks
+ .where((block) => block.isGenerated)
+ .toList();
+ if (sourceBlocks.isEmpty) {
+ if (document.rawFrontMatter != null || source.trim().isNotEmpty) {
+ return document;
+ }
+ final blankOffsets = [
+ 0,
+ for (final match in '\n'.allMatches(source)) match.end,
+ ];
+ return document.copyWith(
+ blocks: [
+ ...frontMatterBlocks,
+ for (final (index, offset) in blankOffsets.indexed)
+ _sourceBlankParagraph(document, offset, index),
+ ...generatedBlocks,
+ ],
+ );
+ }
+ if (sourceBlocks.any((block) => block.sourceSpan == null)) {
+ return document;
+ }
+
+ final expanded = [];
+ var previousEnd = document.rawFrontMatter?.length ?? 0;
+ for (final (index, block) in sourceBlocks.indexed) {
+ final span = block.sourceSpan!;
+ if (span.startOffset < previousEnd || span.endOffset > source.length) {
+ return document;
+ }
+ final gap = source.substring(previousEnd, span.startOffset);
+ if (gap.trim().isNotEmpty) {
+ return document;
+ }
+ // Two newlines are the ordinary Markdown block boundary. Every newline
+ // after that represents another blank paragraph in the rich editor.
+ final baselineNewlines = index == 0 && document.rawFrontMatter == null
+ ? 0
+ : 2;
+ final blankOffsets = _extraBlankLineOffsets(
+ gap,
+ startOffset: previousEnd,
+ baselineNewlines: baselineNewlines,
+ );
+ for (final (blankIndex, offset) in blankOffsets.indexed) {
+ expanded.add(_sourceBlankParagraph(document, offset, blankIndex));
+ }
+ expanded.add(block);
+ previousEnd = span.endOffset;
+ }
+
+ final trailing = source.substring(previousEnd);
+ if (trailing.trim().isNotEmpty) {
+ return document;
+ }
+ final trailingBlankOffsets = _extraBlankLineOffsets(
+ trailing,
+ startOffset: previousEnd,
+ baselineNewlines: 1,
+ );
+ for (final (blankIndex, offset) in trailingBlankOffsets.indexed) {
+ expanded.add(_sourceBlankParagraph(document, offset, blankIndex));
+ }
+ if (expanded.length == sourceBlocks.length) {
+ return document;
+ }
+ return document.copyWith(
+ blocks: [...frontMatterBlocks, ...expanded, ...generatedBlocks],
+ );
+}
+
+List _extraBlankLineOffsets(
+ String gap, {
+ required int startOffset,
+ required int baselineNewlines,
+}) {
+ final newlineOffsets = [
+ for (final match in '\n'.allMatches(gap)) startOffset + match.start,
+ ];
+ if (newlineOffsets.length <= baselineNewlines) {
+ return const [];
+ }
+ final lineStarts = [
+ startOffset,
+ for (final offset in newlineOffsets) offset + 1,
+ ];
+ return [
+ for (var index = baselineNewlines; index < newlineOffsets.length; index++)
+ lineStarts[index],
+ ];
+}
+
+BusyBlock _sourceBlankParagraph(BusyDocument document, int offset, int index) {
+ return BusyBlock(
+ id: '\u0000source-blank:$offset:$index',
+ kind: BusyBlockKind.paragraph,
+ inlines: const [BusyInline(kind: BusyInlineKind.text, text: '')],
+ attributes: const {busyMarkPreserveEmptyParagraphAttribute: 'true'},
+ rawSource: '',
+ sourceSpan: SourceSpan.fromOffsets(
+ filePath: document.filePath,
+ source: document.source ?? '',
+ startOffset: offset,
+ endOffset: offset,
+ ),
+ );
+}
+
bool _selectionCoveredByKind(
List ranges,
BusyInlineKind kind,
@@ -1877,7 +2053,7 @@ List _inlinesFromStyleRanges(
..add(range.end);
}
final sortedBoundaries = boundaries.toList()..sort();
- return [
+ final segments = [
for (var index = 0; index < sortedBoundaries.length - 1; index++)
if (sortedBoundaries[index + 1] > sortedBoundaries[index])
_inlineForSegment(
@@ -1889,6 +2065,57 @@ List _inlinesFromStyleRanges(
),
),
];
+ return _mergeAdjacentInlineStyles(segments);
+}
+
+List _mergeAdjacentInlineStyles(List inlines) {
+ final merged = [];
+ for (final sourceInline in inlines) {
+ final inline = sourceInline.children.isEmpty
+ ? sourceInline
+ : sourceInline.copyWith(
+ children: _mergeAdjacentInlineStyles(sourceInline.children),
+ );
+ if (merged.isEmpty || !_canMergeInlineStyles(merged.last, inline)) {
+ merged.add(inline);
+ continue;
+ }
+ final previous = merged.removeLast();
+ if (inline.kind == BusyInlineKind.text) {
+ merged.add(previous.copyWith(text: previous.text + inline.text));
+ continue;
+ }
+ final children = _mergeAdjacentInlineStyles([
+ ...previous.children,
+ ...inline.children,
+ ]);
+ merged.add(
+ previous.copyWith(
+ text: children.map((child) => child.plainText).join(),
+ children: children,
+ ),
+ );
+ }
+ return merged;
+}
+
+bool _canMergeInlineStyles(BusyInline left, BusyInline right) {
+ if (left.kind != right.kind || left.destination != right.destination) {
+ return false;
+ }
+ if (left.kind == BusyInlineKind.text) {
+ return left.children.isEmpty && right.children.isEmpty;
+ }
+ return left.children.isNotEmpty &&
+ right.children.isNotEmpty &&
+ switch (left.kind) {
+ BusyInlineKind.strong ||
+ BusyInlineKind.emphasis ||
+ BusyInlineKind.underline ||
+ BusyInlineKind.strikethrough ||
+ BusyInlineKind.link => true,
+ _ => false,
+ };
}
List _styleRangesForSlice(
diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart
index 91d4027..a360760 100644
--- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart
+++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@@ -90,15 +91,14 @@ class _BusyMarkWysiwygEditorState extends State {
final _undoStack = [];
final _redoStack = [];
final _itemScrollController = ItemScrollController();
- final _selectionFocusNode = FocusNode(
- debugLabel: 'BusyMark WYSIWYG block selection',
- );
+ late final FocusNode _selectionFocusNode;
String? _activeBlockId;
- String? _selectionStartBlockId;
- String? _selectionEndBlockId;
- int? _selectionStartOffset;
- int? _selectionEndOffset;
- String? _pointerDownBlockId;
+ _DocumentTextSelection? _documentSelection;
+ _DocumentTextPosition? _pointerSelectionAnchor;
+ VerticalCaretMovementRun? _verticalCaretMovement;
+ String? _verticalCaretMovementBlockId;
+ TextPosition? _verticalCaretMovementPosition;
+ double? _preferredVerticalCaretX;
_WysiwygInternalClipboard? _internalClipboard;
final _pendingInlineKindsByBlockId = >{};
int _preserveSelectionFocusCallbacks = 0;
@@ -110,6 +110,10 @@ class _BusyMarkWysiwygEditorState extends State {
@override
void initState() {
super.initState();
+ _selectionFocusNode = FocusNode(
+ debugLabel: 'BusyMark WYSIWYG document selection',
+ onKeyEvent: _handleDocumentSelectionKeyEvent,
+ );
_documentController = BusyMarkWysiwygDocumentController(
document: widget.document,
)..addListener(_handleDocumentControllerChanged);
@@ -174,11 +178,9 @@ class _BusyMarkWysiwygEditorState extends State {
_blockKeys.clear();
_pendingInlineKindsByBlockId.clear();
_activeBlockId = null;
- _selectionStartBlockId = null;
- _selectionEndBlockId = null;
- _selectionStartOffset = null;
- _selectionEndOffset = null;
- _pointerDownBlockId = null;
+ _documentSelection = null;
+ _pointerSelectionAnchor = null;
+ _resetVerticalCaretMovement();
}
@override
@@ -397,6 +399,7 @@ class _BusyMarkWysiwygEditorState extends State {
? _buildEditableBlockField(
block,
first: first,
+ listRunEnd: entry.listRunEnd,
selectedBlockIds: selectedBlockIds,
selectionRangesByBlockId: selectionRangesByBlockId,
)
@@ -410,7 +413,7 @@ class _BusyMarkWysiwygEditorState extends State {
);
final indentedContent = Padding(
padding: EdgeInsetsDirectional.only(
- start: entry.depth * BusyMarkSizes.wysiwygBlockIndent,
+ start: entry.depth * BusyMarkSizes.documentListIndent,
).resolve(blockTextDirection),
child: content,
);
@@ -484,6 +487,7 @@ class _BusyMarkWysiwygEditorState extends State {
Widget _buildEditableBlockField(
BusyBlock block, {
required bool first,
+ required bool listRunEnd,
required Set selectedBlockIds,
required Map
selectionRangesByBlockId,
@@ -493,6 +497,7 @@ class _BusyMarkWysiwygEditorState extends State {
key: _blockKeyFor(block.id),
block: block,
first: first,
+ listRunEnd: listRunEnd,
documentFilePath: documentFilePath,
workspaceRoot: widget.workspaceRoot,
writersideRoot: widget.writersideRoot,
@@ -571,13 +576,11 @@ class _BusyMarkWysiwygEditorState extends State {
_pendingInlineKindsByBlockId.remove(id);
}
}
- if ((_selectionStartBlockId != null &&
- !ids.contains(_selectionStartBlockId)) ||
- (_selectionEndBlockId != null && !ids.contains(_selectionEndBlockId))) {
- _selectionStartBlockId = null;
- _selectionEndBlockId = null;
- _selectionStartOffset = null;
- _selectionEndOffset = null;
+ final documentSelection = _documentSelection;
+ if (documentSelection != null &&
+ (!ids.contains(documentSelection.anchor.blockId) ||
+ !ids.contains(documentSelection.extent.blockId))) {
+ _documentSelection = null;
}
if (_activeBlockId == null || !ids.contains(_activeBlockId)) {
_activeBlockId = blocks.isEmpty ? null : blocks.first.id;
@@ -667,10 +670,13 @@ class _BusyMarkWysiwygEditorState extends State {
int depth = 0,
]) {
final entries = <_EditorRenderEntry>[];
- for (final block in blocks) {
- if (block.kind == BusyBlockKind.frontMatter || block.isSourceOnly) {
- continue;
- }
+ final visibleBlocks = blocks
+ .where(
+ (block) =>
+ block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly,
+ )
+ .toList();
+ for (final (index, block) in visibleBlocks.indexed) {
if (_isStructuralBlockquote(block)) {
entries.add(
_EditorRenderEntry.blockquote(
@@ -681,7 +687,18 @@ class _BusyMarkWysiwygEditorState extends State {
);
continue;
}
- entries.add(_EditorRenderEntry.block(block: block, depth: depth));
+ final nextBlock = index + 1 < visibleBlocks.length
+ ? visibleBlocks[index + 1]
+ : null;
+ entries.add(
+ _EditorRenderEntry.block(
+ block: block,
+ depth: depth,
+ listRunEnd:
+ _isListItemBlock(block) &&
+ (nextBlock == null || !_isListItemBlock(nextBlock)),
+ ),
+ );
if (_showsNestedEditorBlocks(block)) {
entries.addAll(_editorRenderEntries(block.children, depth + 1));
}
@@ -698,19 +715,30 @@ class _BusyMarkWysiwygEditorState extends State {
if (block.isSourceProtected) {
return false;
}
- return switch (block.kind) {
- BusyBlockKind.unorderedListItem ||
- BusyBlockKind.orderedListItem ||
- BusyBlockKind.taskListItem ||
- BusyBlockKind.blockquote => true,
- _ => false,
- };
+ return _isListItemBlock(block) || block.kind == BusyBlockKind.blockquote;
}
+ bool _isListItemBlock(BusyBlock block) => switch (block.kind) {
+ BusyBlockKind.unorderedListItem ||
+ BusyBlockKind.orderedListItem ||
+ BusyBlockKind.taskListItem => true,
+ _ => false,
+ };
+
void _setActiveBlock(String blockId) {
_activeBlockId = blockId;
}
+ KeyEventResult _handleDocumentSelectionKeyEvent(
+ FocusNode node,
+ KeyEvent event,
+ ) {
+ final blockId = _documentSelection?.extent.blockId ?? _activeBlockId;
+ return blockId == null
+ ? KeyEventResult.ignored
+ : _handleBlockKeyEvent(blockId, event);
+ }
+
BusyDocument _historySnapshot() {
final markdown = _documentController.markdown;
return _documentController.document.copyWith(
@@ -740,8 +768,8 @@ class _BusyMarkWysiwygEditorState extends State {
);
}
- void _recordUndoSnapshot() {
- final snapshot = _historySnapshot();
+ void _recordUndoSnapshot([BusyDocument? previousDocument]) {
+ final snapshot = previousDocument ?? _historySnapshot();
if (_undoStack.isNotEmpty && _undoStack.last.source == snapshot.source) {
return;
}
@@ -816,6 +844,15 @@ class _BusyMarkWysiwygEditorState extends State {
if (documentFilePath != _documentController.document.filePath) {
return;
}
+ final selectedDocumentText = _documentSelection;
+ if (selectedDocumentText != null &&
+ selectedDocumentText.extent.blockId == blockId) {
+ final oldText = _documentController.blockText(blockId);
+ final replacement = _replacementTextForFieldEdit(oldText, value);
+ if (_replaceDocumentSelectionWithText(replacement)) {
+ return;
+ }
+ }
_clearBlockSelection();
_setActiveBlock(blockId);
if (_documentController.blockText(blockId) == value) {
@@ -850,6 +887,25 @@ class _BusyMarkWysiwygEditorState extends State {
_emitMarkdown();
}
+ String _replacementTextForFieldEdit(String oldText, String newText) {
+ var prefixLength = 0;
+ final shortestLength = math.min(oldText.length, newText.length);
+ while (prefixLength < shortestLength &&
+ oldText.codeUnitAt(prefixLength) == newText.codeUnitAt(prefixLength)) {
+ prefixLength++;
+ }
+ var oldSuffixStart = oldText.length;
+ var newSuffixStart = newText.length;
+ while (oldSuffixStart > prefixLength &&
+ newSuffixStart > prefixLength &&
+ oldText.codeUnitAt(oldSuffixStart - 1) ==
+ newText.codeUnitAt(newSuffixStart - 1)) {
+ oldSuffixStart--;
+ newSuffixStart--;
+ }
+ return newText.substring(prefixLength, newSuffixStart);
+ }
+
void _handleTableCellTextChanged(
String documentFilePath,
String tableBlockId,
@@ -1140,6 +1196,20 @@ class _BusyMarkWysiwygEditorState extends State {
KeyEventResult _handleBlockKeyEvent(String blockId, KeyEvent event) {
final keyboard = HardwareKeyboard.instance;
final key = event.logicalKey;
+ if ((event is KeyDownEvent || event is KeyRepeatEvent) &&
+ key == LogicalKeyboardKey.tab &&
+ !_hasCommandModifierPressed()) {
+ _activeBlockId = blockId;
+ final block = _documentController.blockById(blockId);
+ if (block != null && _isListItemBlock(block)) {
+ if (keyboard.isShiftPressed) {
+ _applyOutdentCommand();
+ } else {
+ _applyIndentCommand();
+ }
+ return KeyEventResult.handled;
+ }
+ }
if ((event is KeyDownEvent || event is KeyRepeatEvent) &&
BusyMarkTextEditingShortcutActivators.insertIndentation.accepts(
event,
@@ -1150,7 +1220,13 @@ class _BusyMarkWysiwygEditorState extends State {
? KeyEventResult.handled
: KeyEventResult.ignored;
}
- if (event is! KeyDownEvent) {
+ final isRepeatedArrowKey =
+ event is KeyRepeatEvent &&
+ (key == LogicalKeyboardKey.arrowUp ||
+ key == LogicalKeyboardKey.arrowDown ||
+ key == LogicalKeyboardKey.arrowLeft ||
+ key == LogicalKeyboardKey.arrowRight);
+ if (event is! KeyDownEvent && !isRepeatedArrowKey) {
return KeyEventResult.ignored;
}
_activeBlockId = blockId;
@@ -1200,6 +1276,18 @@ class _BusyMarkWysiwygEditorState extends State {
_applyEditorShortcutAction(shortcutAction);
return KeyEventResult.handled;
}
+ if (keyboard.isControlPressed &&
+ !keyboard.isAltPressed &&
+ !keyboard.isMetaPressed &&
+ (key == LogicalKeyboardKey.arrowLeft ||
+ key == LogicalKeyboardKey.arrowRight)) {
+ final boundaryResult = keyboard.isShiftPressed
+ ? _extendKeyboardSelectionByWord(blockId, key)
+ : _moveWordCaretAcrossBlockBoundary(blockId, key);
+ if (boundaryResult == KeyEventResult.handled) {
+ return boundaryResult;
+ }
+ }
if (_hasCommandModifierPressed()) {
return KeyEventResult.ignored;
}
@@ -1208,12 +1296,34 @@ class _BusyMarkWysiwygEditorState extends State {
_deleteBlockSelection()) {
return KeyEventResult.handled;
}
+ if (key == LogicalKeyboardKey.enter &&
+ _hasBlockSelection &&
+ _replaceDocumentSelectionWithText('\n')) {
+ return KeyEventResult.handled;
+ }
final controller = _textControllers[blockId];
if (controller == null || !controller.selection.isValid) {
return KeyEventResult.ignored;
}
final selection = controller.selection;
final shiftPressed = HardwareKeyboard.instance.isShiftPressed;
+ final isArrowKey =
+ key == LogicalKeyboardKey.arrowUp ||
+ key == LogicalKeyboardKey.arrowDown ||
+ key == LogicalKeyboardKey.arrowLeft ||
+ key == LogicalKeyboardKey.arrowRight;
+ final isVerticalArrow =
+ key == LogicalKeyboardKey.arrowUp ||
+ key == LogicalKeyboardKey.arrowDown;
+ if (!isVerticalArrow) {
+ _resetVerticalCaretMovement();
+ }
+ if (shiftPressed && isArrowKey) {
+ return _extendKeyboardSelection(blockId, key);
+ }
+ if (!shiftPressed && isArrowKey && _hasBlockSelection) {
+ return _collapseDocumentSelectionForArrow(key);
+ }
if (!selection.isCollapsed) {
if (key == LogicalKeyboardKey.enter && !shiftPressed) {
final start = math
@@ -1261,35 +1371,6 @@ class _BusyMarkWysiwygEditorState extends State {
final nextBlockKey = textDirection == TextDirection.rtl
? LogicalKeyboardKey.arrowLeft
: LogicalKeyboardKey.arrowRight;
- if (shiftPressed) {
- if (key == LogicalKeyboardKey.arrowUp &&
- _isOffsetOnFirstTextLine(controller.text, offset)) {
- return _extendSelectionToRelativeBlock(
- blockId,
- -1,
- desiredOffset: _MoveToBlockEnd(),
- );
- }
- if (key == LogicalKeyboardKey.arrowDown &&
- _isOffsetOnLastTextLine(controller.text, offset)) {
- return _extendSelectionToRelativeBlock(
- blockId,
- 1,
- desiredOffset: offset,
- );
- }
- if (key == previousBlockKey && offset == 0) {
- return _extendSelectionToRelativeBlock(
- blockId,
- -1,
- desiredOffset: _MoveToBlockEnd(),
- );
- }
- if (key == nextBlockKey && offset == controller.text.length) {
- return _extendSelectionToRelativeBlock(blockId, 1, desiredOffset: 0);
- }
- return KeyEventResult.ignored;
- }
if (key == LogicalKeyboardKey.enter) {
final activeInlineKinds = _activeInlineKindsAt(blockId, offset);
_recordUndoSnapshot();
@@ -1314,13 +1395,11 @@ class _BusyMarkWysiwygEditorState extends State {
_focusBlockAfterFrame(result.blockId, offset: result.offset);
return KeyEventResult.handled;
}
- if (key == LogicalKeyboardKey.arrowUp &&
- _isOffsetOnFirstTextLine(controller.text, offset)) {
- return _focusRelativeBlock(blockId, -1, desiredOffset: offset);
+ if (key == LogicalKeyboardKey.arrowUp) {
+ return _moveCaretVertically(blockId, -1);
}
- if (key == LogicalKeyboardKey.arrowDown &&
- _isOffsetOnLastTextLine(controller.text, offset)) {
- return _focusRelativeBlock(blockId, 1, desiredOffset: offset);
+ if (key == LogicalKeyboardKey.arrowDown) {
+ return _moveCaretVertically(blockId, 1);
}
if (key == previousBlockKey && offset == 0) {
return _focusRelativeBlock(blockId, -1, desiredOffset: _MoveToBlockEnd());
@@ -1338,12 +1417,35 @@ class _BusyMarkWysiwygEditorState extends State {
keyboard.isMetaPressed;
}
- bool _isOffsetOnFirstTextLine(String text, int offset) {
- return !text.substring(0, offset.clamp(0, text.length)).contains('\n');
- }
-
- bool _isOffsetOnLastTextLine(String text, int offset) {
- return !text.substring(offset.clamp(0, text.length)).contains('\n');
+ KeyEventResult _moveWordCaretAcrossBlockBoundary(
+ String blockId,
+ LogicalKeyboardKey key,
+ ) {
+ if (_hasBlockSelection) {
+ return KeyEventResult.ignored;
+ }
+ final controller = _textControllers[blockId];
+ final selection = controller?.selection;
+ if (controller == null ||
+ selection == null ||
+ !selection.isValid ||
+ !selection.isCollapsed) {
+ return KeyEventResult.ignored;
+ }
+ final position = _DocumentTextPosition(
+ blockId: blockId,
+ offset: selection.extentOffset.clamp(0, controller.text.length).toInt(),
+ affinity: selection.affinity,
+ );
+ final target = _horizontalCaretTarget(position, key);
+ if (target == null || target.blockId == blockId) {
+ return KeyEventResult.ignored;
+ }
+ _resetVerticalCaretMovement();
+ _applyKeyboardSelection(
+ _DocumentTextSelection(anchor: target, extent: target),
+ );
+ return KeyEventResult.handled;
}
KeyEventResult _focusRelativeBlock(
@@ -1372,43 +1474,425 @@ class _BusyMarkWysiwygEditorState extends State {
return KeyEventResult.handled;
}
- KeyEventResult _extendSelectionToRelativeBlock(
+ KeyEventResult _extendKeyboardSelection(
String blockId,
- int direction, {
- required Object desiredOffset,
- }) {
+ LogicalKeyboardKey key,
+ ) {
+ final selection = _keyboardSelectionFor(blockId);
+ if (selection == null) {
+ return KeyEventResult.ignored;
+ }
+ final extent = selection.extent;
+ final _DocumentTextPosition? target;
+ if (key == LogicalKeyboardKey.arrowUp) {
+ target = _verticalCaretTarget(extent, -1);
+ } else if (key == LogicalKeyboardKey.arrowDown) {
+ target = _verticalCaretTarget(extent, 1);
+ } else if (key == LogicalKeyboardKey.arrowLeft ||
+ key == LogicalKeyboardKey.arrowRight) {
+ target = _horizontalCaretTarget(extent, key);
+ } else {
+ target = null;
+ }
+ if (target == null) {
+ return KeyEventResult.handled;
+ }
+ _applyKeyboardSelection(
+ _DocumentTextSelection(anchor: selection.anchor, extent: target),
+ );
+ return KeyEventResult.handled;
+ }
+
+ KeyEventResult _extendKeyboardSelectionByWord(
+ String blockId,
+ LogicalKeyboardKey key,
+ ) {
+ final selection = _keyboardSelectionFor(blockId);
+ if (selection == null) {
+ return KeyEventResult.ignored;
+ }
+ final target = _wordCaretTarget(selection.extent, key);
+ if (target == null) {
+ return KeyEventResult.ignored;
+ }
+ _resetVerticalCaretMovement();
+ _applyKeyboardSelection(
+ _DocumentTextSelection(anchor: selection.anchor, extent: target),
+ );
+ return KeyEventResult.handled;
+ }
+
+ _DocumentTextSelection? _keyboardSelectionFor(String fallbackBlockId) {
+ final documentSelection = _documentSelection;
+ if (documentSelection != null) {
+ return documentSelection;
+ }
+ final controller = _textControllers[fallbackBlockId];
+ final selection = controller?.selection;
+ if (controller == null || selection == null || !selection.isValid) {
+ return null;
+ }
+ return _DocumentTextSelection(
+ anchor: _DocumentTextPosition(
+ blockId: fallbackBlockId,
+ offset: selection.baseOffset.clamp(0, controller.text.length).toInt(),
+ ),
+ extent: _DocumentTextPosition(
+ blockId: fallbackBlockId,
+ offset: selection.extentOffset.clamp(0, controller.text.length).toInt(),
+ affinity: selection.affinity,
+ ),
+ );
+ }
+
+ _DocumentTextPosition? _horizontalCaretTarget(
+ _DocumentTextPosition position,
+ LogicalKeyboardKey key,
+ ) {
+ final block = _documentController.blockById(position.blockId);
+ final controller = _textControllers[position.blockId];
+ if (block == null || controller == null) {
+ return null;
+ }
+ final textDirection = busyMarkWysiwygBlockTextDirection(
+ block,
+ fallback: Directionality.of(context),
+ );
+ final forwardKey = textDirection == TextDirection.rtl
+ ? LogicalKeyboardKey.arrowLeft
+ : LogicalKeyboardKey.arrowRight;
+ final forward = key == forwardKey;
+ final offset = position.offset.clamp(0, controller.text.length).toInt();
+ final boundary = CharacterBoundary(controller.text);
+ if (forward && offset < controller.text.length) {
+ return _DocumentTextPosition(
+ blockId: position.blockId,
+ offset:
+ boundary.getTrailingTextBoundaryAt(offset) ??
+ controller.text.length,
+ );
+ }
+ if (!forward && offset > 0) {
+ return _DocumentTextPosition(
+ blockId: position.blockId,
+ offset: boundary.getLeadingTextBoundaryAt(offset - 1) ?? 0,
+ );
+ }
+ final nextBlock = _relativeFocusableBlock(
+ position.blockId,
+ forward ? 1 : -1,
+ );
+ if (nextBlock == null) {
+ return position;
+ }
+ final nextController = _textControllerFor(nextBlock);
+ return _DocumentTextPosition(
+ blockId: nextBlock.id,
+ offset: forward ? 0 : nextController.text.length,
+ );
+ }
+
+ _DocumentTextPosition? _wordCaretTarget(
+ _DocumentTextPosition position,
+ LogicalKeyboardKey key,
+ ) {
+ final block = _documentController.blockById(position.blockId);
+ final controller = _textControllers[position.blockId];
+ if (block == null || controller == null) {
+ return null;
+ }
+ final textDirection = busyMarkWysiwygBlockTextDirection(
+ block,
+ fallback: Directionality.of(context),
+ );
+ final forwardKey = textDirection == TextDirection.rtl
+ ? LogicalKeyboardKey.arrowLeft
+ : LogicalKeyboardKey.arrowRight;
+ final forward = key == forwardKey;
+ final offset = position.offset.clamp(0, controller.text.length).toInt();
+ if ((forward && offset < controller.text.length) ||
+ (!forward && offset > 0)) {
+ final wordBoundary = _renderEditableForBlock(
+ position.blockId,
+ )?.wordBoundaries.moveByWordBoundary;
+ if (wordBoundary == null) {
+ return null;
+ }
+ return _DocumentTextPosition(
+ blockId: position.blockId,
+ offset: forward
+ ? wordBoundary.getTrailingTextBoundaryAt(offset) ??
+ controller.text.length
+ : wordBoundary.getLeadingTextBoundaryAt(offset - 1) ?? 0,
+ );
+ }
+ final nextBlock = _relativeFocusableBlock(
+ position.blockId,
+ forward ? 1 : -1,
+ );
+ if (nextBlock == null) {
+ return position;
+ }
+ final nextController = _textControllerFor(nextBlock);
+ return _DocumentTextPosition(
+ blockId: nextBlock.id,
+ offset: forward ? 0 : nextController.text.length,
+ );
+ }
+
+ KeyEventResult _moveCaretVertically(String blockId, int direction) {
+ final controller = _textControllers[blockId];
+ final selection = controller?.selection;
+ if (controller == null || selection == null || !selection.isValid) {
+ return KeyEventResult.ignored;
+ }
+ final target = _verticalCaretTarget(
+ _DocumentTextPosition(
+ blockId: blockId,
+ offset: selection.extentOffset.clamp(0, controller.text.length).toInt(),
+ affinity: selection.affinity,
+ ),
+ direction,
+ );
+ if (target == null) {
+ return KeyEventResult.ignored;
+ }
+ _applyKeyboardSelection(
+ _DocumentTextSelection(anchor: target, extent: target),
+ );
+ return KeyEventResult.handled;
+ }
+
+ _DocumentTextPosition? _verticalCaretTarget(
+ _DocumentTextPosition position,
+ int direction,
+ ) {
+ final controller = _textControllers[position.blockId];
+ if (controller == null) {
+ return null;
+ }
+ final currentPosition = TextPosition(
+ offset: position.offset.clamp(0, controller.text.length).toInt(),
+ affinity: position.affinity,
+ );
+ final renderEditable = _renderEditableForBlock(position.blockId);
+ if (renderEditable != null && renderEditable.hasSize) {
+ final runMatches =
+ _verticalCaretMovementBlockId == position.blockId &&
+ _verticalCaretMovementPosition == currentPosition &&
+ (_verticalCaretMovement?.isValid ?? false);
+ if (!runMatches) {
+ _verticalCaretMovement = renderEditable.startVerticalCaretMovement(
+ currentPosition,
+ );
+ _verticalCaretMovementBlockId = position.blockId;
+ _verticalCaretMovementPosition = currentPosition;
+ final caret = renderEditable.getLocalRectForCaret(currentPosition);
+ _preferredVerticalCaretX = renderEditable
+ .localToGlobal(caret.topLeft)
+ .dx;
+ }
+ final run = _verticalCaretMovement!;
+ final moved = direction < 0 ? run.movePrevious() : run.moveNext();
+ if (moved) {
+ final target = run.current;
+ _verticalCaretMovementPosition = target;
+ return _DocumentTextPosition(
+ blockId: position.blockId,
+ offset: target.offset,
+ affinity: target.affinity,
+ );
+ }
+ }
+
+ final nextBlock = _relativeFocusableBlock(position.blockId, direction);
+ if (nextBlock == null) {
+ final boundaryOffset = direction < 0 ? 0 : controller.text.length;
+ final target = _DocumentTextPosition(
+ blockId: position.blockId,
+ offset: boundaryOffset,
+ );
+ _verticalCaretMovementPosition = TextPosition(offset: boundaryOffset);
+ return target;
+ }
+ final nextController = _textControllerFor(nextBlock);
+ final nextRenderEditable = _renderEditableForBlock(nextBlock.id);
+ var targetPosition = TextPosition(
+ offset: position.offset.clamp(0, nextController.text.length).toInt(),
+ );
+ if (nextRenderEditable != null && nextRenderEditable.hasSize) {
+ final edgePosition = TextPosition(
+ offset: direction < 0 ? nextController.text.length : 0,
+ affinity: direction < 0
+ ? TextAffinity.upstream
+ : TextAffinity.downstream,
+ );
+ final edgeCaret = nextRenderEditable.getLocalRectForCaret(edgePosition);
+ final edgeGlobal = nextRenderEditable.localToGlobal(edgeCaret.center);
+ targetPosition = nextRenderEditable.getPositionForPoint(
+ Offset(_preferredVerticalCaretX ?? edgeGlobal.dx, edgeGlobal.dy),
+ );
+ _verticalCaretMovement = nextRenderEditable.startVerticalCaretMovement(
+ targetPosition,
+ );
+ } else {
+ _verticalCaretMovement = null;
+ }
+ _verticalCaretMovementBlockId = nextBlock.id;
+ _verticalCaretMovementPosition = targetPosition;
+ return _DocumentTextPosition(
+ blockId: nextBlock.id,
+ offset: targetPosition.offset
+ .clamp(0, nextController.text.length)
+ .toInt(),
+ affinity: targetPosition.affinity,
+ );
+ }
+
+ BusyBlock? _relativeFocusableBlock(String blockId, int direction) {
final blocks = _focusableBlocks();
final index = blocks.indexWhere((block) => block.id == blockId);
if (index == -1) {
+ return null;
+ }
+ final targetIndex = index + direction;
+ return targetIndex < 0 || targetIndex >= blocks.length
+ ? null
+ : blocks[targetIndex];
+ }
+
+ RenderEditable? _renderEditableForBlock(String blockId) {
+ final renderObject = _blockKeys[blockId]?.currentContext
+ ?.findRenderObject();
+ return renderObject == null ? null : _findRenderEditable(renderObject);
+ }
+
+ RenderEditable? _findRenderEditable(RenderObject renderObject) {
+ if (renderObject is RenderEditable) {
+ return renderObject;
+ }
+ RenderEditable? result;
+ renderObject.visitChildren((child) {
+ result ??= _findRenderEditable(child);
+ });
+ return result;
+ }
+
+ void _applyKeyboardSelection(_DocumentTextSelection selection) {
+ final anchor = _clampDocumentPosition(selection.anchor);
+ final extent = _clampDocumentPosition(selection.extent);
+ if (anchor == null || extent == null) {
+ return;
+ }
+ final nextSelection = _DocumentTextSelection(
+ anchor: anchor,
+ extent: extent,
+ );
+ final extentBlock = _documentController.blockById(extent.blockId);
+ if (extentBlock == null) {
+ return;
+ }
+ final controller = _textControllerFor(extentBlock);
+ final focusNode = _focusNodeFor(extentBlock);
+ _activeBlockId = extent.blockId;
+ if (anchor.blockId == extent.blockId) {
+ if (_documentSelection != null) {
+ setState(() => _documentSelection = null);
+ }
+ focusNode.requestFocus();
+ controller.selection = TextSelection(
+ baseOffset: anchor.offset,
+ extentOffset: extent.offset,
+ affinity: extent.affinity,
+ );
+ _collapseInactiveFieldSelections(extent.blockId);
+ return;
+ }
+ setState(() => _documentSelection = nextSelection);
+ if (!_selectionFocusNode.hasPrimaryFocus) {
+ focusNode.requestFocus();
+ }
+ controller.selection = TextSelection.collapsed(
+ offset: extent.offset,
+ affinity: extent.affinity,
+ );
+ _collapseInactiveFieldSelections(extent.blockId);
+ }
+
+ _DocumentTextPosition? _clampDocumentPosition(
+ _DocumentTextPosition position,
+ ) {
+ final controller = _textControllers[position.blockId];
+ if (controller == null) {
+ return null;
+ }
+ return _DocumentTextPosition(
+ blockId: position.blockId,
+ offset: position.offset.clamp(0, controller.text.length).toInt(),
+ affinity: position.affinity,
+ );
+ }
+
+ KeyEventResult _collapseDocumentSelectionForArrow(LogicalKeyboardKey key) {
+ final selection = _documentSelection;
+ if (selection == null) {
return KeyEventResult.ignored;
}
- final nextIndex = index + direction;
- if (nextIndex < 0 || nextIndex >= blocks.length) {
+ final ordered = _orderedDocumentSelection(selection);
+ if (ordered == null) {
return KeyEventResult.ignored;
}
- final anchor = _selectionAnchorForBlock(blockId);
- if (anchor == null) {
+ final extentBlock = _documentController.blockById(selection.extent.blockId);
+ if (extentBlock == null) {
return KeyEventResult.ignored;
}
- final nextBlock = blocks[nextIndex];
- final controller = _textControllerFor(nextBlock);
- final focusNode = _focusNodeFor(nextBlock);
- final offset = desiredOffset is _MoveToBlockEnd
- ? controller.text.length
- : (desiredOffset as int).clamp(0, controller.text.length).toInt();
- _activeBlockId = nextBlock.id;
- focusNode.requestFocus();
- controller.selection = TextSelection.collapsed(offset: offset);
- setState(() {
- _selectionStartBlockId = anchor.blockId;
- _selectionStartOffset = anchor.offset;
- _selectionEndBlockId = nextBlock.id;
- _selectionEndOffset = offset;
- });
- _collapseFieldSelections(exceptBlockId: nextBlock.id);
+ final textDirection = busyMarkWysiwygBlockTextDirection(
+ extentBlock,
+ fallback: Directionality.of(context),
+ );
+ final towardStartKey = textDirection == TextDirection.rtl
+ ? LogicalKeyboardKey.arrowRight
+ : LogicalKeyboardKey.arrowLeft;
+ final towardStart =
+ key == LogicalKeyboardKey.arrowUp || key == towardStartKey;
+ final target = towardStart ? ordered.start : ordered.end;
+ _resetVerticalCaretMovement();
+ _applyKeyboardSelection(
+ _DocumentTextSelection(anchor: target, extent: target),
+ );
return KeyEventResult.handled;
}
+ _OrderedDocumentSelection? _orderedDocumentSelection(
+ _DocumentTextSelection selection,
+ ) {
+ final blocks = _focusableBlocks();
+ final anchorIndex = blocks.indexWhere(
+ (block) => block.id == selection.anchor.blockId,
+ );
+ final extentIndex = blocks.indexWhere(
+ (block) => block.id == selection.extent.blockId,
+ );
+ if (anchorIndex == -1 || extentIndex == -1) {
+ return null;
+ }
+ final anchorFirst =
+ anchorIndex < extentIndex ||
+ (anchorIndex == extentIndex &&
+ selection.anchor.offset <= selection.extent.offset);
+ return _OrderedDocumentSelection(
+ start: anchorFirst ? selection.anchor : selection.extent,
+ end: anchorFirst ? selection.extent : selection.anchor,
+ );
+ }
+
+ void _resetVerticalCaretMovement() {
+ _verticalCaretMovement = null;
+ _verticalCaretMovementBlockId = null;
+ _verticalCaretMovementPosition = null;
+ _preferredVerticalCaretX = null;
+ }
+
void _focusBlockAfterFrame(String blockId, {required int offset}) {
_activeBlockId = blockId;
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -1755,6 +2239,9 @@ class _BusyMarkWysiwygEditorState extends State {
bool _pasteInternalClipboardIntoActiveBlock(
_WysiwygInternalClipboard clipboard,
) {
+ if (_hasBlockSelection) {
+ return _replaceDocumentSelectionWithStyledBlocks(clipboard.blocks);
+ }
final blockId = _activeBlockId;
if (blockId == null) {
return false;
@@ -1805,6 +2292,9 @@ class _BusyMarkWysiwygEditorState extends State {
if (text == null || text.isEmpty) {
return;
}
+ if (_hasBlockSelection && _replaceDocumentSelectionWithText(text)) {
+ return;
+ }
final currentText = controller.text;
final selection = controller.selection.isValid
? controller.selection
@@ -1830,6 +2320,9 @@ class _BusyMarkWysiwygEditorState extends State {
}
bool _insertTabIntoBlock(String blockId) {
+ if (_hasBlockSelection) {
+ return _replaceDocumentSelectionWithText('\t');
+ }
final controller = _textControllers[blockId];
if (controller == null) {
return false;
@@ -2024,26 +2517,34 @@ class _BusyMarkWysiwygEditorState extends State {
.toInt();
}
- void _applyIndentCommand() {
+ bool _applyIndentCommand() {
final blockIds = _commandTargetBlockIds();
if (blockIds.isEmpty) {
- return;
+ return false;
}
- _recordUndoSnapshot();
- _documentController.indentListItems(blockIds);
+ final undoSnapshot = _historySnapshot();
+ if (!_documentController.indentListItems(blockIds)) {
+ return false;
+ }
+ _recordUndoSnapshot(undoSnapshot);
_clearBlockSelection();
_emitMarkdown();
+ return true;
}
- void _applyOutdentCommand() {
+ bool _applyOutdentCommand() {
final blockIds = _commandTargetBlockIds();
if (blockIds.isEmpty) {
- return;
+ return false;
}
- _recordUndoSnapshot();
- _documentController.outdentListItems(blockIds);
+ final undoSnapshot = _historySnapshot();
+ if (!_documentController.outdentListItems(blockIds)) {
+ return false;
+ }
+ _recordUndoSnapshot(undoSnapshot);
_clearBlockSelection();
_emitMarkdown();
+ return true;
}
void _applyToggleTaskCommand() {
@@ -2287,25 +2788,23 @@ class _BusyMarkWysiwygEditorState extends State {
});
}
- bool get _hasBlockSelection {
- return _selectionStartBlockId != null &&
- _selectionEndBlockId != null &&
- _selectionStartOffset != null &&
- _selectionEndOffset != null;
- }
+ bool get _hasBlockSelection => _documentSelection != null;
GlobalKey _blockKeyFor(String blockId) {
return _blockKeys.putIfAbsent(blockId, GlobalKey.new);
}
Set _selectedBlockIds(List blocks) {
- final startId = _selectionStartBlockId;
- final endId = _selectionEndBlockId;
- if (startId == null || endId == null) {
+ final selection = _documentSelection;
+ if (selection == null) {
return const {};
}
- final startIndex = blocks.indexWhere((block) => block.id == startId);
- final endIndex = blocks.indexWhere((block) => block.id == endId);
+ final startIndex = blocks.indexWhere(
+ (block) => block.id == selection.anchor.blockId,
+ );
+ final endIndex = blocks.indexWhere(
+ (block) => block.id == selection.extent.blockId,
+ );
if (startIndex == -1 || endIndex == -1) {
return const {};
}
@@ -2321,27 +2820,29 @@ class _BusyMarkWysiwygEditorState extends State {
};
}
- List<_SelectedTextRange> _selectedTextRanges([List? inputBlocks]) {
- final startId = _selectionStartBlockId;
- final endId = _selectionEndBlockId;
- final rawStartOffset = _selectionStartOffset;
- final rawEndOffset = _selectionEndOffset;
- if (startId == null ||
- endId == null ||
- rawStartOffset == null ||
- rawEndOffset == null) {
+ List<_SelectedTextRange> _selectedTextRanges([
+ List? inputBlocks,
+ bool includeEmptyRanges = false,
+ ]) {
+ final selection = _documentSelection;
+ if (selection == null) {
return const [];
}
final blocks =
inputBlocks ?? _editableBlocks(_documentController.document.blocks);
- final startIndex = blocks.indexWhere((block) => block.id == startId);
- final endIndex = blocks.indexWhere((block) => block.id == endId);
+ final startIndex = blocks.indexWhere(
+ (block) => block.id == selection.anchor.blockId,
+ );
+ final endIndex = blocks.indexWhere(
+ (block) => block.id == selection.extent.blockId,
+ );
if (startIndex == -1 || endIndex == -1) {
return const [];
}
final forward =
startIndex < endIndex ||
- (startIndex == endIndex && rawStartOffset <= rawEndOffset);
+ (startIndex == endIndex &&
+ selection.anchor.offset <= selection.extent.offset);
final lower = math.min(startIndex, endIndex);
final upper = math.max(startIndex, endIndex);
final ranges = <_SelectedTextRange>[];
@@ -2353,11 +2854,11 @@ class _BusyMarkWysiwygEditorState extends State {
index: index,
startIndex: startIndex,
endIndex: endIndex,
- startOffset: rawStartOffset.clamp(0, textLength).toInt(),
- endOffset: rawEndOffset.clamp(0, textLength).toInt(),
+ startOffset: selection.anchor.offset.clamp(0, textLength).toInt(),
+ endOffset: selection.extent.offset.clamp(0, textLength).toInt(),
forward: forward,
);
- if (range != null && range.end > range.start) {
+ if (range != null && (includeEmptyRanges || range.end > range.start)) {
ranges.add(range);
}
}
@@ -2427,18 +2928,17 @@ class _BusyMarkWysiwygEditorState extends State {
];
}
- _SelectionAnchor? _selectionAnchorForBlock(String fallbackBlockId) {
- final startBlockId = _selectionStartBlockId;
- final startOffset = _selectionStartOffset;
- if (startBlockId != null && startOffset != null) {
- return _SelectionAnchor(blockId: startBlockId, offset: startOffset);
+ _DocumentTextPosition? _selectionAnchorForBlock(String fallbackBlockId) {
+ final documentSelection = _documentSelection;
+ if (documentSelection != null) {
+ return documentSelection.anchor;
}
final controller = _textControllers[fallbackBlockId];
final selection = controller?.selection;
if (controller == null || selection == null || !selection.isValid) {
return null;
}
- return _SelectionAnchor(
+ return _DocumentTextPosition(
blockId: fallbackBlockId,
offset: selection.baseOffset.clamp(0, controller.text.length).toInt(),
);
@@ -2448,17 +2948,18 @@ class _BusyMarkWysiwygEditorState extends State {
if (event.buttons != kPrimaryMouseButton) {
return;
}
+ _resetVerticalCaretMovement();
final offset = _textOffsetAtGlobalPosition(blockId, event.position);
if (HardwareKeyboard.instance.isShiftPressed) {
final anchor = _selectionAnchorForBlock(_activeBlockId ?? blockId);
if (anchor != null) {
- _pointerDownBlockId = anchor.blockId;
+ _pointerSelectionAnchor = anchor;
_activeBlockId = blockId;
setState(() {
- _selectionStartBlockId = anchor.blockId;
- _selectionStartOffset = anchor.offset;
- _selectionEndBlockId = blockId;
- _selectionEndOffset = offset;
+ _documentSelection = _DocumentTextSelection(
+ anchor: anchor,
+ extent: _DocumentTextPosition(blockId: blockId, offset: offset),
+ );
});
_preserveSelectionFocusCallbacks = 2;
_collapseFieldSelections();
@@ -2468,37 +2969,42 @@ class _BusyMarkWysiwygEditorState extends State {
}
_clearBlockSelection();
_collapseInactiveFieldSelections(blockId);
- _pointerDownBlockId = blockId;
- _selectionStartOffset = offset;
- _selectionEndOffset = null;
+ _pointerSelectionAnchor = _DocumentTextPosition(
+ blockId: blockId,
+ offset: offset,
+ );
}
void _handleBlockPointerMove(PointerMoveEvent event) {
- if (_pointerDownBlockId == null || event.buttons != kPrimaryMouseButton) {
+ if (_pointerSelectionAnchor == null ||
+ event.buttons != kPrimaryMouseButton) {
return;
}
_updateBlockSelectionDrag(event.position);
}
bool _updateBlockSelectionDrag(Offset position) {
- final startBlockId = _pointerDownBlockId;
- if (startBlockId == null) {
+ final anchor = _pointerSelectionAnchor;
+ if (anchor == null) {
return false;
}
final targetBlockId = _blockIdAtGlobalPosition(position);
- if (targetBlockId == null || targetBlockId == startBlockId) {
+ if (targetBlockId == null) {
+ return false;
+ }
+ if (targetBlockId == anchor.blockId && _documentSelection == null) {
return false;
}
final endOffset = _textOffsetAtGlobalPosition(targetBlockId, position);
- if (_selectionStartBlockId == startBlockId &&
- _selectionEndBlockId == targetBlockId &&
- _selectionEndOffset == endOffset) {
+ final nextSelection = _DocumentTextSelection(
+ anchor: anchor,
+ extent: _DocumentTextPosition(blockId: targetBlockId, offset: endOffset),
+ );
+ if (_documentSelection == nextSelection) {
return false;
}
setState(() {
- _selectionStartBlockId = startBlockId;
- _selectionEndBlockId = targetBlockId;
- _selectionEndOffset = endOffset;
+ _documentSelection = nextSelection;
});
_collapseFieldSelections();
_selectionFocusNode.requestFocus();
@@ -2507,7 +3013,14 @@ class _BusyMarkWysiwygEditorState extends State {
void _handleBlockPointerUp(PointerUpEvent event) {
_updateBlockSelectionDrag(event.position);
- _pointerDownBlockId = null;
+ _pointerSelectionAnchor = null;
+ if (_hasBlockSelection) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) {
+ _focusDocumentSelectionExtent();
+ }
+ });
+ }
if (_preserveSelectionFocusCallbacks > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_preserveSelectionFocusCallbacks = 0;
@@ -2562,17 +3075,11 @@ class _BusyMarkWysiwygEditorState extends State {
void _clearBlockSelection({bool collapseFields = true}) {
if (!_hasBlockSelection) {
- _selectionStartBlockId = null;
- _selectionEndBlockId = null;
- _selectionStartOffset = null;
- _selectionEndOffset = null;
+ _documentSelection = null;
return;
}
setState(() {
- _selectionStartBlockId = null;
- _selectionEndBlockId = null;
- _selectionStartOffset = null;
- _selectionEndOffset = null;
+ _documentSelection = null;
});
if (collapseFields) {
_collapseFieldSelections();
@@ -2634,22 +3141,47 @@ class _BusyMarkWysiwygEditorState extends State {
final first = blocks.first;
final last = blocks.last;
setState(() {
- _selectionStartBlockId = first.id;
- _selectionStartOffset = 0;
- _selectionEndBlockId = last.id;
- _selectionEndOffset = last.plainText.length;
+ _documentSelection = _DocumentTextSelection(
+ anchor: _DocumentTextPosition(blockId: first.id, offset: 0),
+ extent: _DocumentTextPosition(
+ blockId: last.id,
+ offset: last.plainText.length,
+ ),
+ );
});
_collapseFieldSelections();
- _selectionFocusNode.requestFocus();
+ _focusDocumentSelectionExtent();
+ }
+
+ void _focusDocumentSelectionExtent() {
+ final extent = _documentSelection?.extent;
+ if (extent == null) {
+ return;
+ }
+ final block = _documentController.blockById(extent.blockId);
+ if (block == null) {
+ return;
+ }
+ final controller = _textControllerFor(block);
+ _activeBlockId = extent.blockId;
+ _focusNodeFor(block).requestFocus();
+ controller.selection = TextSelection.collapsed(
+ offset: extent.offset.clamp(0, controller.text.length).toInt(),
+ affinity: extent.affinity,
+ );
+ _collapseInactiveFieldSelections(extent.blockId);
}
bool _deleteBlockSelection() {
- final ranges = _selectedTextRanges();
+ final ranges = _selectedTextRanges(null, true);
if (ranges.isEmpty) {
return false;
}
final first = ranges.first;
final last = ranges.last;
+ if (first.block.id == last.block.id && first.start == last.end) {
+ return false;
+ }
_recordUndoSnapshot();
final result = _documentController.deleteTextSelection(
firstBlockId: first.block.id,
@@ -2667,6 +3199,106 @@ class _BusyMarkWysiwygEditorState extends State {
return true;
}
+ bool _replaceDocumentSelectionWithText(String replacementText) {
+ final ranges = _selectedTextRanges(null, true);
+ if (ranges.isEmpty) {
+ return false;
+ }
+ final first = ranges.first;
+ final last = ranges.last;
+ if (first.block.id == last.block.id && first.start == last.end) {
+ return false;
+ }
+ final normalizedReplacement = replacementText
+ .replaceAll('\r\n', '\n')
+ .replaceAll('\r', '\n');
+ final activeInlineKinds = _activeInlineKindsAt(first.block.id, first.start);
+ _recordUndoSnapshot();
+ final deletion = _documentController.deleteTextSelection(
+ firstBlockId: first.block.id,
+ firstStartOffset: first.start,
+ lastBlockId: last.block.id,
+ lastEndOffset: last.end,
+ removedBlockIds: ranges.map((range) => range.block.id),
+ );
+ if (deletion == null) {
+ return false;
+ }
+ final mergedText = _documentController.blockText(deletion.blockId);
+ final insertionOffset = deletion.offset.clamp(0, mergedText.length).toInt();
+ final nextText = mergedText.replaceRange(
+ insertionOffset,
+ insertionOffset,
+ normalizedReplacement,
+ );
+ final splitResult = _documentController.replaceBlockTextWithParagraphs(
+ deletion.blockId,
+ nextText,
+ insertionOffset + normalizedReplacement.length,
+ );
+ final focusResult =
+ splitResult ??
+ BusyWysiwygTextSplitResult(
+ blockId: deletion.blockId,
+ offset: insertionOffset + normalizedReplacement.length,
+ );
+ if (splitResult == null) {
+ _documentController.updateBlockText(
+ deletion.blockId,
+ nextText,
+ activeInlineKinds: activeInlineKinds,
+ );
+ }
+ if (nextText.isEmpty) {
+ _setPendingInlineKinds(deletion.blockId, activeInlineKinds);
+ }
+ _clearBlockSelection(collapseFields: false);
+ _emitMarkdown();
+ _focusBlockAfterFrame(focusResult.blockId, offset: focusResult.offset);
+ return true;
+ }
+
+ bool _replaceDocumentSelectionWithStyledBlocks(
+ List blocks,
+ ) {
+ if (blocks.isEmpty) {
+ return false;
+ }
+ final ranges = _selectedTextRanges(null, true);
+ if (ranges.isEmpty) {
+ return false;
+ }
+ final first = ranges.first;
+ final last = ranges.last;
+ if (first.block.id == last.block.id && first.start == last.end) {
+ return false;
+ }
+ _recordUndoSnapshot();
+ final deletion = _documentController.deleteTextSelection(
+ firstBlockId: first.block.id,
+ firstStartOffset: first.start,
+ lastBlockId: last.block.id,
+ lastEndOffset: last.end,
+ removedBlockIds: ranges.map((range) => range.block.id),
+ );
+ if (deletion == null) {
+ return false;
+ }
+ final result = _documentController.insertStyledBlocksAtSelection(
+ blockId: deletion.blockId,
+ selectionStart: deletion.offset,
+ selectionEnd: deletion.offset,
+ blocks: blocks,
+ );
+ if (result == null) {
+ return false;
+ }
+ _clearBlockSelection(collapseFields: false);
+ _emitMarkdown();
+ _focusBlockAfterFrame(result.blockId, offset: result.offset);
+ return true;
+ }
+
void _copyBlockSelection() {
_copyCurrentSelection();
}
@@ -2676,6 +3308,9 @@ class _BusyMarkWysiwygEditorState extends State {
}
bool _copyCurrentSelection() {
+ if (_hasBlockSelection) {
+ return _copyDocumentSelectionToClipboard();
+ }
final ranges = _currentSelectionRanges();
if (ranges.isEmpty) {
return false;
@@ -2684,16 +3319,53 @@ class _BusyMarkWysiwygEditorState extends State {
}
bool _cutCurrentSelection() {
+ if (_hasBlockSelection) {
+ if (!_copyDocumentSelectionToClipboard()) {
+ return false;
+ }
+ return _deleteBlockSelection();
+ }
final ranges = _currentSelectionRanges();
if (ranges.isEmpty || !_copyRangesToClipboard(ranges)) {
return false;
}
- if (_hasBlockSelection) {
- return _deleteBlockSelection();
- }
return _deleteActiveTextSelection(ranges.single);
}
+ bool _copyDocumentSelectionToClipboard() {
+ final allRanges = _selectedTextRanges(null, true);
+ if (allRanges.isEmpty) {
+ return false;
+ }
+ final ranges = allRanges.any((range) => range.end > range.start)
+ ? _withoutEmptySelectionEndpoints(allRanges)
+ : allRanges;
+ final clipboardText = ranges.map(_copyTextForRange).join('\n\n');
+ final clipboardBlocks = [
+ for (final range in ranges) _styledBlockForRange(range),
+ ];
+ _internalClipboard = _WysiwygInternalClipboard(
+ text: clipboardText,
+ blocks: clipboardBlocks,
+ );
+ unawaited(Clipboard.setData(ClipboardData(text: clipboardText)));
+ return true;
+ }
+
+ List<_SelectedTextRange> _withoutEmptySelectionEndpoints(
+ List<_SelectedTextRange> ranges,
+ ) {
+ var start = 0;
+ var end = ranges.length;
+ while (start < end && ranges[start].start == ranges[start].end) {
+ start++;
+ }
+ while (end > start && ranges[end - 1].start == ranges[end - 1].end) {
+ end--;
+ }
+ return ranges.sublist(start, end);
+ }
+
bool _copyRangesToClipboard(List<_SelectedTextRange> ranges) {
final clipboardBlocks = [];
final clipboardTexts = [];
@@ -2865,9 +3537,7 @@ class _BusyMarkWysiwygEditorState extends State {
first: _isFirstRootBlock(block.id),
);
final contentPadding = busyMarkWysiwygTextLayoutInsets(block);
- final prefixWidth = busyMarkWysiwygHasPrefix(block)
- ? BusyMarkSizes.wysiwygPrefixWidth + BusyMarkSpacing.sm
- : 0.0;
+ final prefixWidth = busyMarkWysiwygPrefixExtent(block);
final textDirection = busyMarkWysiwygBlockTextDirection(
block,
fallback: Directionality.of(context),
@@ -2906,27 +3576,9 @@ class _BusyMarkWysiwygEditorState extends State {
}
TextStyle _textStyleForBlock(BusyBlock block) {
- final theme = Theme.of(context).textTheme;
final level = int.tryParse(block.attributes['level'] ?? '') ?? 0;
return switch (block.kind) {
- BusyBlockKind.heading when level == 1 => theme.headlineSmall!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading when level == 2 => theme.titleLarge!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading when level == 3 => theme.titleMedium!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading when level == 4 => theme.titleSmall!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading when level == 5 => theme.bodyLarge!.copyWith(
- fontWeight: FontWeight.w700,
- ),
- BusyBlockKind.heading => theme.bodyMedium!.copyWith(
- fontWeight: FontWeight.w700,
- ),
+ BusyBlockKind.heading => busyMarkDocumentHeadingTextStyle(context, level),
BusyBlockKind.codeBlock => busyMarkDocumentCodeTextStyle(context),
_ => busyMarkDocumentBodyTextStyle(context),
};
@@ -2964,11 +3616,51 @@ class _WysiwygDialogTarget {
final int generation;
}
-class _SelectionAnchor {
- const _SelectionAnchor({required this.blockId, required this.offset});
+class _DocumentTextPosition {
+ const _DocumentTextPosition({
+ required this.blockId,
+ required this.offset,
+ this.affinity = TextAffinity.downstream,
+ });
final String blockId;
final int offset;
+ final TextAffinity affinity;
+
+ @override
+ bool operator ==(Object other) {
+ return other is _DocumentTextPosition &&
+ other.blockId == blockId &&
+ other.offset == offset &&
+ other.affinity == affinity;
+ }
+
+ @override
+ int get hashCode => Object.hash(blockId, offset, affinity);
+}
+
+class _DocumentTextSelection {
+ const _DocumentTextSelection({required this.anchor, required this.extent});
+
+ final _DocumentTextPosition anchor;
+ final _DocumentTextPosition extent;
+
+ @override
+ bool operator ==(Object other) {
+ return other is _DocumentTextSelection &&
+ other.anchor == anchor &&
+ other.extent == extent;
+ }
+
+ @override
+ int get hashCode => Object.hash(anchor, extent);
+}
+
+class _OrderedDocumentSelection {
+ const _OrderedDocumentSelection({required this.start, required this.end});
+
+ final _DocumentTextPosition start;
+ final _DocumentTextPosition end;
}
class _WysiwygInternalClipboard {
@@ -2986,17 +3678,21 @@ class _EditableBlockEntry {
}
class _EditorRenderEntry {
- const _EditorRenderEntry.block({required this.block, required this.depth})
- : children = null;
+ const _EditorRenderEntry.block({
+ required this.block,
+ required this.depth,
+ required this.listRunEnd,
+ }) : children = null;
const _EditorRenderEntry.blockquote({
required this.block,
required this.depth,
required this.children,
- });
+ }) : listRunEnd = false;
final BusyBlock block;
final int depth;
+ final bool listRunEnd;
final List<_EditorRenderEntry>? children;
}
diff --git a/lib/src/export/markdown_export_assets.dart b/lib/src/export/markdown_export_assets.dart
new file mode 100644
index 0000000..5bc875a
--- /dev/null
+++ b/lib/src/export/markdown_export_assets.dart
@@ -0,0 +1,269 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:crypto/crypto.dart';
+import 'package:path/path.dart' as p;
+import 'package:xml/xml.dart' as xml;
+
+import '../core/local_image_resolver.dart';
+import '../core/uri_utils.dart';
+import 'markdown_export_document.dart';
+import 'markdown_pdf_models.dart';
+
+class MarkdownExportAssetResult {
+ const MarkdownExportAssetResult({
+ required this.assets,
+ required this.warnings,
+ });
+
+ /// Original Markdown destination to a safe path relative to the export root.
+ final Map assets;
+ final List warnings;
+}
+
+class MarkdownExportAssetStager {
+ const MarkdownExportAssetStager({
+ this.maxAssetBytes = 16 * 1024 * 1024,
+ this.maxTotalBytes = 64 * 1024 * 1024,
+ this.maxAssets = 128,
+ });
+
+ final int maxAssetBytes;
+ final int maxTotalBytes;
+ final int maxAssets;
+
+ Future stage({
+ required MarkdownExportDocument document,
+ required Directory exportRoot,
+ required String activeFilePath,
+ required String workspaceRoot,
+ required MarkdownPdfCancellationToken cancellationToken,
+ }) async {
+ final assetDirectory = Directory(p.join(exportRoot.path, 'assets'));
+ await assetDirectory.create();
+ final assets = {};
+ final warnings = [];
+ var totalBytes = 0;
+ var processedAssets = 0;
+
+ for (final destination in document.imageDestinations.toSet()) {
+ cancellationToken.throwIfCancelled();
+ if (processedAssets >= maxAssets) {
+ warnings.add(
+ MarkdownPdfWarning(
+ MarkdownPdfWarningCode.imageLimitReached,
+ destination,
+ ),
+ );
+ continue;
+ }
+ processedAssets++;
+ final uri = parseSchemedUri(destination);
+ if (uri != null && isRemoteResourceUriScheme(uri.scheme)) {
+ warnings.add(
+ MarkdownPdfWarning(
+ MarkdownPdfWarningCode.remoteImageSkipped,
+ destination,
+ ),
+ );
+ continue;
+ }
+ if (uri != null && uri.scheme.toLowerCase() != 'file') {
+ warnings.add(
+ MarkdownPdfWarning(
+ MarkdownPdfWarningCode.imageUnsupported,
+ destination,
+ ),
+ );
+ continue;
+ }
+
+ final resolverDestination = uri?.scheme.toLowerCase() == 'file'
+ ? _fileUriPath(uri!)
+ : destination;
+ final resolved = resolverDestination == null
+ ? null
+ : resolveLocalImagePath(
+ activeFilePath: activeFilePath,
+ destination: resolverDestination,
+ workspaceRoot: workspaceRoot.isEmpty ? null : workspaceRoot,
+ );
+ if (resolved == null) {
+ warnings.add(
+ MarkdownPdfWarning(MarkdownPdfWarningCode.imageNotFound, destination),
+ );
+ continue;
+ }
+ final extension = p.extension(resolved).toLowerCase();
+ if (!const {
+ '.png',
+ '.jpg',
+ '.jpeg',
+ '.gif',
+ '.svg',
+ }.contains(extension)) {
+ warnings.add(
+ MarkdownPdfWarning(
+ MarkdownPdfWarningCode.imageUnsupported,
+ destination,
+ ),
+ );
+ continue;
+ }
+ try {
+ final size = await File(resolved).length();
+ if (size > maxAssetBytes || totalBytes + size > maxTotalBytes) {
+ warnings.add(
+ MarkdownPdfWarning(
+ MarkdownPdfWarningCode.imageTooLarge,
+ destination,
+ ),
+ );
+ continue;
+ }
+ final bytes = await File(resolved).readAsBytes();
+ cancellationToken.throwIfCancelled();
+ if (!_hasExpectedFormat(bytes, extension)) {
+ warnings.add(
+ MarkdownPdfWarning(
+ MarkdownPdfWarningCode.imageUnsupported,
+ destination,
+ ),
+ );
+ continue;
+ }
+ final name = '${sha256.convert(bytes)}$extension';
+ final relativePath = p.posix.join('assets', name);
+ final target = File(p.join(assetDirectory.path, name));
+ if (!await target.exists()) {
+ await target.writeAsBytes(bytes, flush: true);
+ totalBytes += bytes.length;
+ }
+ assets[destination] = relativePath;
+ } on Object {
+ warnings.add(
+ MarkdownPdfWarning(
+ MarkdownPdfWarningCode.imageReadFailed,
+ destination,
+ ),
+ );
+ }
+ }
+ return MarkdownExportAssetResult(
+ assets: Map.unmodifiable(assets),
+ warnings: List.unmodifiable(warnings),
+ );
+ }
+
+ String? _fileUriPath(Uri uri) {
+ try {
+ return uri.toFilePath();
+ } on UnsupportedError {
+ return null;
+ }
+ }
+
+ bool _hasExpectedFormat(List bytes, String extension) {
+ bool startsWith(List signature) {
+ if (bytes.length < signature.length) {
+ return false;
+ }
+ for (var index = 0; index < signature.length; index++) {
+ if (bytes[index] != signature[index]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ return switch (extension) {
+ '.png' => startsWith(const [
+ 0x89,
+ 0x50,
+ 0x4e,
+ 0x47,
+ 0x0d,
+ 0x0a,
+ 0x1a,
+ 0x0a,
+ ]),
+ '.jpg' || '.jpeg' => startsWith(const [0xff, 0xd8, 0xff]),
+ '.gif' => startsWith('GIF8'.codeUnits),
+ '.svg' => _looksLikeSafeSvg(bytes),
+ _ => false,
+ };
+ }
+
+ bool _looksLikeSafeSvg(List bytes) {
+ try {
+ final source = utf8.decode(bytes);
+ final normalizedSource = source.toLowerCase();
+ if (normalizedSource.contains('[
+ root,
+ ...root.descendants.whereType(),
+ ]) {
+ if (blockedElements.contains(element.name.local.toLowerCase())) {
+ return false;
+ }
+ for (final attribute in element.attributes) {
+ final name = attribute.name.local.toLowerCase();
+ final value = attribute.value.trim();
+ if (name.startsWith('on') ||
+ ((name == 'href' || name == 'src') &&
+ !_isSafeSvgReference(value)) ||
+ !_hasOnlyLocalCssUrls(value)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ } on Object {
+ return false;
+ }
+ }
+
+ bool _isSafeSvgReference(String value) {
+ final normalized = value.toLowerCase();
+ return value.isEmpty ||
+ value.startsWith('#') ||
+ normalized.startsWith('data:image/png;base64,') ||
+ normalized.startsWith('data:image/jpeg;base64,') ||
+ normalized.startsWith('data:image/gif;base64,');
+ }
+
+ bool _hasOnlyLocalCssUrls(String value) {
+ for (final match in RegExp(
+ r'''url\(\s*(['"]?)(.*?)\1\s*\)''',
+ caseSensitive: false,
+ ).allMatches(value)) {
+ if (!(match.group(2) ?? '').trim().startsWith('#')) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/lib/src/export/markdown_export_document.dart b/lib/src/export/markdown_export_document.dart
new file mode 100644
index 0000000..75b39fc
--- /dev/null
+++ b/lib/src/export/markdown_export_document.dart
@@ -0,0 +1,123 @@
+import 'package:flutter/foundation.dart';
+
+enum MarkdownExportBlockKind {
+ heading,
+ paragraph,
+ code,
+ list,
+ listItem,
+ blockquote,
+ thematicBreak,
+ image,
+ table,
+ tableRow,
+ tableCell,
+ rawText,
+ group,
+}
+
+enum MarkdownExportInlineKind {
+ text,
+ strong,
+ emphasis,
+ underline,
+ strikethrough,
+ code,
+ link,
+ image,
+ softBreak,
+ hardBreak,
+}
+
+@immutable
+class MarkdownExportMetadata {
+ const MarkdownExportMetadata({
+ required this.title,
+ this.author = '',
+ this.description = '',
+ this.language = 'en',
+ this.keywords = const [],
+ });
+
+ final String title;
+ final String author;
+ final String description;
+ final String language;
+ final List keywords;
+
+ Map toJson() => {
+ 'title': title,
+ 'author': author,
+ 'description': description,
+ 'language': language,
+ 'keywords': keywords,
+ };
+}
+
+@immutable
+class MarkdownExportDocument {
+ const MarkdownExportDocument({required this.metadata, required this.blocks});
+
+ final MarkdownExportMetadata metadata;
+ final List blocks;
+
+ Iterable get imageDestinations sync* {
+ for (final block in blocks) {
+ yield* block.imageDestinations;
+ }
+ }
+}
+
+@immutable
+class MarkdownExportBlock {
+ const MarkdownExportBlock({
+ required this.kind,
+ this.inlines = const [],
+ this.children = const [],
+ this.attributes = const {},
+ this.text = '',
+ });
+
+ final MarkdownExportBlockKind kind;
+ final List inlines;
+ final List children;
+ final Map attributes;
+ final String text;
+
+ Iterable get imageDestinations sync* {
+ for (final inline in inlines) {
+ yield* inline.imageDestinations;
+ }
+ for (final child in children) {
+ yield* child.imageDestinations;
+ }
+ }
+}
+
+@immutable
+class MarkdownExportInline {
+ const MarkdownExportInline({
+ required this.kind,
+ this.text = '',
+ this.destination,
+ this.children = const [],
+ this.attributes = const {},
+ });
+
+ final MarkdownExportInlineKind kind;
+ final String text;
+ final String? destination;
+ final List children;
+ final Map attributes;
+
+ Iterable get imageDestinations sync* {
+ if (kind == MarkdownExportInlineKind.image &&
+ destination != null &&
+ destination!.trim().isNotEmpty) {
+ yield destination!;
+ }
+ for (final child in children) {
+ yield* child.imageDestinations;
+ }
+ }
+}
diff --git a/lib/src/export/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart
new file mode 100644
index 0000000..6c9880b
--- /dev/null
+++ b/lib/src/export/markdown_export_mapper.dart
@@ -0,0 +1,366 @@
+import 'package:path/path.dart' as p;
+
+import '../core/uri_utils.dart';
+import '../markdown/busymark_document.dart';
+import 'markdown_export_document.dart';
+
+class MarkdownExportMapper {
+ const MarkdownExportMapper();
+
+ MarkdownExportDocument map(BusyDocument document) {
+ return MarkdownExportDocument(
+ metadata: _metadata(document),
+ blocks: _mapBlocks(document.blocks),
+ );
+ }
+
+ MarkdownExportMetadata _metadata(BusyDocument document) {
+ final frontMatter = {
+ for (final entry in document.frontMatter.entries)
+ entry.key.toLowerCase().trim(): entry.value.trim(),
+ };
+ final title = _firstNonEmpty([
+ frontMatter['title'],
+ document.title,
+ document.filePath.isEmpty
+ ? null
+ : p.basenameWithoutExtension(document.filePath),
+ 'Untitled',
+ ])!;
+ final keywords = _firstNonEmpty([
+ frontMatter['keywords'],
+ frontMatter['tags'],
+ ]);
+ return MarkdownExportMetadata(
+ title: title,
+ author:
+ _firstNonEmpty([frontMatter['author'], frontMatter['authors']]) ?? '',
+ description:
+ _firstNonEmpty([
+ frontMatter['description'],
+ frontMatter['summary'],
+ ]) ??
+ '',
+ language: _normalizedLanguage(
+ _firstNonEmpty([frontMatter['lang'], frontMatter['language']]),
+ ),
+ keywords: keywords == null
+ ? const []
+ : keywords
+ .split(RegExp(r'[,;]'))
+ .map((value) => value.trim())
+ .where((value) => value.isNotEmpty)
+ .take(32)
+ .toList(growable: false),
+ );
+ }
+
+ List _mapBlocks(List blocks) {
+ final result = [];
+ var index = 0;
+ while (index < blocks.length) {
+ final block = blocks[index];
+ if (_isListItem(block.kind)) {
+ final ordered = _isOrderedListItem(block);
+ final items = [];
+ final start = _listNumber(block.attributes['marker']) ?? 1;
+ while (index < blocks.length && _isListItem(blocks[index].kind)) {
+ final candidate = blocks[index];
+ final candidateOrdered = _isOrderedListItem(candidate);
+ if (candidateOrdered != ordered) {
+ break;
+ }
+ items.add(_mapListItem(candidate));
+ index++;
+ }
+ result.add(
+ MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.list,
+ children: items,
+ attributes: {'ordered': ordered, 'start': start},
+ ),
+ );
+ continue;
+ }
+ final mapped = _mapBlock(block);
+ if (mapped != null) {
+ result.add(mapped);
+ }
+ index++;
+ }
+ return List.unmodifiable(result);
+ }
+
+ MarkdownExportBlock _mapListItem(BusyBlock block) {
+ return MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.listItem,
+ inlines: _mapInlines(block.inlines),
+ children: _mapBlocks(block.children),
+ attributes: {
+ if (block.attributes['task'] case final task?) 'task': task == 'true',
+ },
+ );
+ }
+
+ MarkdownExportBlock? _mapBlock(BusyBlock block) {
+ return switch (block.kind) {
+ BusyBlockKind.heading => MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.heading,
+ inlines: _mapInlines(block.inlines),
+ attributes: {
+ 'level':
+ int.tryParse(block.attributes['level'] ?? '')?.clamp(1, 6) ?? 1,
+ if (_safeAnchor(block.attributes['id']) case final id?) 'id': id,
+ },
+ ),
+ BusyBlockKind.paragraph => MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.paragraph,
+ inlines: _mapInlines(block.inlines),
+ ),
+ BusyBlockKind.codeBlock => MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.code,
+ text: block.plainText,
+ attributes: {
+ if (_safeCodeLanguage(block.attributes['language'])
+ case final language?)
+ 'language': language,
+ },
+ ),
+ BusyBlockKind.blockquote => MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.blockquote,
+ inlines: _mapInlines(block.inlines),
+ children: _mapBlocks(block.children),
+ ),
+ BusyBlockKind.thematicBreak => const MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.thematicBreak,
+ ),
+ BusyBlockKind.image => _mapImageBlock(block),
+ BusyBlockKind.table => _mapTable(block),
+ BusyBlockKind.htmlBlock when block.children.isNotEmpty =>
+ MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.group,
+ children: _mapBlocks(block.children),
+ ),
+ BusyBlockKind.htmlBlock => MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.rawText,
+ text: block.rawSource ?? block.plainText,
+ ),
+ BusyBlockKind.frontMatter => null,
+ BusyBlockKind.unorderedListItem ||
+ BusyBlockKind.orderedListItem ||
+ BusyBlockKind.taskListItem => _mapListItem(block),
+ BusyBlockKind.writersideAdmonition ||
+ BusyBlockKind.writersideTabs ||
+ BusyBlockKind.writersideProcedure ||
+ BusyBlockKind.writersideRawXml ||
+ BusyBlockKind.unknown => MarkdownExportBlock(
+ kind: block.children.isEmpty
+ ? MarkdownExportBlockKind.rawText
+ : MarkdownExportBlockKind.group,
+ inlines: _mapInlines(block.inlines),
+ children: _mapBlocks(block.children),
+ text: block.rawSource ?? block.plainText,
+ ),
+ };
+ }
+
+ MarkdownExportBlock _mapTable(BusyBlock table) {
+ return MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.table,
+ children: [
+ for (final row in table.children)
+ MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.tableRow,
+ attributes: {'header': row.attributes['header'] == 'true'},
+ children: [
+ for (final cell in row.children)
+ MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.tableCell,
+ inlines: _mapInlines(cell.inlines),
+ children: _mapBlocks(cell.children),
+ attributes: {
+ if (_safeAlignment(cell.attributes['align'])
+ case final alignment?)
+ 'align': alignment,
+ },
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+
+ MarkdownExportBlock _mapImageBlock(BusyBlock block) {
+ final onlyInline = block.inlines.length == 1 ? block.inlines.single : null;
+ if (onlyInline?.kind == BusyInlineKind.link &&
+ onlyInline!.children.length == 1 &&
+ onlyInline.children.single.kind == BusyInlineKind.image) {
+ return MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.image,
+ inlines: [_mapInline(onlyInline.children.single)],
+ attributes: {
+ if (_safeLinkDestination(onlyInline.destination)
+ case final destination?)
+ 'destination': destination,
+ },
+ );
+ }
+ return MarkdownExportBlock(
+ kind: MarkdownExportBlockKind.image,
+ inlines: _mapInlines(block.inlines),
+ );
+ }
+
+ List _mapInlines(List inlines) {
+ return List.unmodifiable(inlines.map(_mapInline));
+ }
+
+ MarkdownExportInline _mapInline(BusyInline inline) {
+ final children = _mapInlines(inline.children);
+ return switch (inline.kind) {
+ BusyInlineKind.text => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.text,
+ text: inline.text,
+ ),
+ BusyInlineKind.strong => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.strong,
+ text: inline.text,
+ children: children,
+ ),
+ BusyInlineKind.emphasis => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.emphasis,
+ text: inline.text,
+ children: children,
+ ),
+ BusyInlineKind.underline => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.underline,
+ text: inline.text,
+ children: children,
+ ),
+ BusyInlineKind.strikethrough => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.strikethrough,
+ text: inline.text,
+ children: children,
+ ),
+ BusyInlineKind.code => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.code,
+ text: inline.text,
+ ),
+ BusyInlineKind.link => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.link,
+ text: inline.text,
+ destination: _safeLinkDestination(inline.destination),
+ children: children,
+ ),
+ BusyInlineKind.image => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.image,
+ text: inline.text,
+ destination: _safeImageDestination(inline.destination),
+ attributes: {
+ if (inline.attributes['title'] case final title?) 'title': title,
+ },
+ ),
+ BusyInlineKind.softBreak => const MarkdownExportInline(
+ kind: MarkdownExportInlineKind.softBreak,
+ ),
+ BusyInlineKind.hardBreak => const MarkdownExportInline(
+ kind: MarkdownExportInlineKind.hardBreak,
+ ),
+ BusyInlineKind.html ||
+ BusyInlineKind.writersideVariable ||
+ BusyInlineKind.unknown => MarkdownExportInline(
+ kind: MarkdownExportInlineKind.text,
+ text: inline.plainText,
+ ),
+ };
+ }
+
+ bool _isListItem(BusyBlockKind kind) {
+ return kind == BusyBlockKind.unorderedListItem ||
+ kind == BusyBlockKind.orderedListItem ||
+ kind == BusyBlockKind.taskListItem;
+ }
+
+ bool _isOrderedListItem(BusyBlock block) {
+ return block.kind == BusyBlockKind.orderedListItem ||
+ block.attributes['ordered'] == 'true';
+ }
+
+ int? _listNumber(String? marker) {
+ if (marker == null) {
+ return null;
+ }
+ return int.tryParse(marker.replaceAll(RegExp(r'[^0-9]'), ''));
+ }
+
+ String? _safeCodeLanguage(String? value) {
+ final language = value?.trim().toLowerCase();
+ if (language == null ||
+ language.isEmpty ||
+ !RegExp(r'^[a-z0-9_+.#-]{1,40}$').hasMatch(language)) {
+ return null;
+ }
+ return language;
+ }
+
+ String? _safeAnchor(String? value) {
+ final anchor = value?.trim();
+ if (anchor == null ||
+ anchor.isEmpty ||
+ anchor.length > 256 ||
+ anchor.runes.any((rune) => rune < 0x20 || rune == 0x7f)) {
+ return null;
+ }
+ return anchor;
+ }
+
+ String? _safeLinkDestination(String? value) {
+ final destination = value?.trim();
+ if (destination == null ||
+ destination.isEmpty ||
+ destination.length > 4096) {
+ return null;
+ }
+ if (destination.startsWith('#')) {
+ return _safeAnchor(destination.substring(1)) == null ? null : destination;
+ }
+ final uri = parseSchemedUri(destination);
+ return uri != null && isLaunchableExternalUri(uri) ? uri.toString() : null;
+ }
+
+ String? _safeImageDestination(String? value) {
+ final destination = value?.trim();
+ if (destination == null ||
+ destination.isEmpty ||
+ destination.length > 4096 ||
+ destination.runes.any((rune) => rune == 0 || rune < 0x09)) {
+ return null;
+ }
+ return destination;
+ }
+
+ String? _safeAlignment(String? value) {
+ final normalized = value?.trim().toLowerCase();
+ return const {'left', 'center', 'right'}.contains(normalized)
+ ? normalized
+ : null;
+ }
+
+ String _normalizedLanguage(String? value) {
+ final normalized = value?.trim().replaceAll('_', '-').toLowerCase();
+ if (normalized == null ||
+ !RegExp(r'^[a-z]{2,3}(?:-[a-z]{2})?$').hasMatch(normalized)) {
+ return 'en';
+ }
+ return normalized.split('-').first;
+ }
+
+ String? _firstNonEmpty(Iterable values) {
+ for (final value in values) {
+ if (value != null && value.trim().isNotEmpty) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+}
diff --git a/lib/src/export/markdown_pdf_export_service.dart b/lib/src/export/markdown_pdf_export_service.dart
new file mode 100644
index 0000000..279c729
--- /dev/null
+++ b/lib/src/export/markdown_pdf_export_service.dart
@@ -0,0 +1,239 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/services.dart';
+import 'package:path/path.dart' as p;
+
+import '../core/atomic_file_writer.dart';
+import '../markdown/markdown_parser.dart';
+import 'markdown_export_assets.dart';
+import 'markdown_export_mapper.dart';
+import 'markdown_pdf_models.dart';
+import 'typst_compiler.dart';
+import 'typst_payload_builder.dart';
+
+typedef TypstTemplateLoader = Future Function();
+
+class MarkdownPdfExportService {
+ const MarkdownPdfExportService({
+ this.parser = const MarkdownParser(),
+ this.mapper = const MarkdownExportMapper(),
+ this.assetStager = const MarkdownExportAssetStager(),
+ this.payloadBuilder = const TypstPayloadBuilder(),
+ this.compilerLocator = const TypstCompilerLocator(),
+ this.commandRunner = const DartTypstCommandRunner(),
+ this.fileWriter = const AtomicFileWriter(),
+ this.templateLoader = _loadBundledTemplate,
+ this.compileTimeout = const Duration(seconds: 45),
+ this.maximumPdfBytes = 100 * 1024 * 1024,
+ });
+
+ final MarkdownParser parser;
+ final MarkdownExportMapper mapper;
+ final MarkdownExportAssetStager assetStager;
+ final TypstPayloadBuilder payloadBuilder;
+ final TypstCompilerLocator compilerLocator;
+ final TypstCommandRunner commandRunner;
+ final AtomicFileWriter fileWriter;
+ final TypstTemplateLoader templateLoader;
+ final Duration compileTimeout;
+ final int maximumPdfBytes;
+
+ Future export(
+ MarkdownPdfExportRequest request, {
+ MarkdownPdfCancellationToken? cancellationToken,
+ }) async {
+ final token = cancellationToken ?? MarkdownPdfCancellationToken();
+ token.throwIfCancelled();
+ final executable = compilerLocator.locate();
+ if (executable == null) {
+ throw const MarkdownPdfExportException(
+ MarkdownPdfFailureCode.compilerUnavailable,
+ detail: 'The bundled Typst compiler could not be found.',
+ );
+ }
+
+ final exportRoot = await Directory.systemTemp.createTemp(
+ 'busymark-pdf-export-',
+ );
+ try {
+ final effectiveFilePath = request.filePath.isEmpty
+ ? p.join(
+ request.workspaceRoot.isEmpty
+ ? exportRoot.path
+ : request.workspaceRoot,
+ 'untitled.md',
+ )
+ : request.filePath;
+ final parsed = await parser.parseAsync(
+ filePath: effectiveFilePath,
+ source: request.source,
+ workspaceRoot: request.workspaceRoot.isEmpty
+ ? null
+ : request.workspaceRoot,
+ validateLocalReferences: false,
+ );
+ token.throwIfCancelled();
+ final document = mapper.map(parsed.busyDocument);
+ final stagedAssets = await assetStager.stage(
+ document: document,
+ exportRoot: exportRoot,
+ activeFilePath: effectiveFilePath,
+ workspaceRoot: request.workspaceRoot,
+ cancellationToken: token,
+ );
+ final payload = payloadBuilder.build(
+ document: document,
+ options: request.options,
+ assets: stagedAssets.assets,
+ );
+ await Future.wait([
+ File(
+ p.join(exportRoot.path, 'document.json'),
+ ).writeAsString(jsonEncode(payload), flush: true),
+ templateLoader().then(
+ (template) => File(
+ p.join(exportRoot.path, 'document.typ'),
+ ).writeAsString(template, flush: true),
+ ),
+ ]);
+ token.throwIfCancelled();
+ final processResult = await commandRunner.compile(
+ executable: executable,
+ workingDirectory: exportRoot,
+ timeout: compileTimeout,
+ cancellationToken: token,
+ );
+ if (processResult.exitCode != 0) {
+ throw MarkdownPdfExportException(
+ MarkdownPdfFailureCode.compilerFailed,
+ detail: _safeCompilerDetail(processResult.stderr),
+ );
+ }
+ final output = File(p.join(exportRoot.path, 'output.pdf'));
+ if (!await output.exists()) {
+ throw const MarkdownPdfExportException(
+ MarkdownPdfFailureCode.invalidOutput,
+ detail: 'Typst did not produce a PDF file.',
+ );
+ }
+ final outputSize = await output.length();
+ if (outputSize <= 8 || outputSize > maximumPdfBytes) {
+ throw const MarkdownPdfExportException(
+ MarkdownPdfFailureCode.invalidOutput,
+ detail: 'The generated PDF has an invalid size.',
+ );
+ }
+ final pdfBytes = await output.readAsBytes();
+ if (!_isPdf(pdfBytes)) {
+ throw const MarkdownPdfExportException(
+ MarkdownPdfFailureCode.invalidOutput,
+ detail: 'The generated file is not a valid PDF.',
+ );
+ }
+ token.throwIfCancelled();
+ try {
+ await fileWriter.writeBytes(
+ request.destinationPath,
+ pdfBytes,
+ overwrite: request.overwrite,
+ );
+ } on AtomicFileAlreadyExistsException catch (error) {
+ throw MarkdownPdfExportException(
+ MarkdownPdfFailureCode.destinationExists,
+ detail: error.path,
+ cause: error,
+ );
+ } on FileSystemException catch (error) {
+ throw MarkdownPdfExportException(
+ MarkdownPdfFailureCode.fileSystem,
+ detail: error.message,
+ cause: error,
+ );
+ }
+ return MarkdownPdfExportResult(
+ destinationPath: p.normalize(p.absolute(request.destinationPath)),
+ pageCount: _pageCount(pdfBytes),
+ warnings: stagedAssets.warnings,
+ );
+ } on MarkdownPdfExportException {
+ rethrow;
+ } on FileSystemException catch (error) {
+ throw MarkdownPdfExportException(
+ MarkdownPdfFailureCode.fileSystem,
+ detail: error.message,
+ cause: error,
+ );
+ } finally {
+ await _deleteExportRootBestEffort(exportRoot);
+ }
+ }
+
+ static Future _loadBundledTemplate() {
+ return rootBundle.loadString('assets/export/markdown.typ');
+ }
+
+ String _safeCompilerDetail(String stderr) {
+ final normalized = stderr
+ .replaceAll(RegExp(r'[\r\n]+'), ' ')
+ .replaceAll(RegExp(r'\s+'), ' ')
+ .trim();
+ return normalized.length <= 1000
+ ? normalized
+ : '${normalized.substring(0, 1000)}…';
+ }
+
+ bool _isPdf(List bytes) {
+ const header = [0x25, 0x50, 0x44, 0x46, 0x2d];
+ if (bytes.length < header.length ||
+ !List.generate(
+ header.length,
+ (index) => bytes[index] == header[index],
+ ).every((matches) => matches)) {
+ return false;
+ }
+ final tailStart = (bytes.length - 2048).clamp(0, bytes.length);
+ return latin1.decode(bytes.sublist(tailStart)).contains('%%EOF');
+ }
+
+ int? _pageCount(List bytes) {
+ const needle = [
+ 0x2f,
+ 0x54,
+ 0x79,
+ 0x70,
+ 0x65,
+ 0x20,
+ 0x2f,
+ 0x50,
+ 0x61,
+ 0x67,
+ 0x65,
+ ];
+ var count = 0;
+ for (var index = 0; index <= bytes.length - needle.length; index++) {
+ var matches = true;
+ for (var offset = 0; offset < needle.length; offset++) {
+ if (bytes[index + offset] != needle[offset]) {
+ matches = false;
+ break;
+ }
+ }
+ if (matches) {
+ count++;
+ index += needle.length - 1;
+ }
+ }
+ return count == 0 ? null : count;
+ }
+
+ Future _deleteExportRootBestEffort(Directory directory) async {
+ try {
+ if (await directory.exists()) {
+ await directory.delete(recursive: true);
+ }
+ } on Object {
+ // Export completion and failure must not be hidden by cleanup.
+ }
+ }
+}
diff --git a/lib/src/export/markdown_pdf_export_ui.dart b/lib/src/export/markdown_pdf_export_ui.dart
new file mode 100644
index 0000000..8484fd8
--- /dev/null
+++ b/lib/src/export/markdown_pdf_export_ui.dart
@@ -0,0 +1,422 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:file_selector/file_selector.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:path/path.dart' as p;
+import 'package:url_launcher/url_launcher.dart';
+
+import '../app/busymark_dialogs.dart';
+import '../app/busymark_design.dart';
+import '../app/busymark_glyphs.dart';
+import '../app/localization.dart';
+import '../platform/linux_header_bar_service.dart';
+import '../workspace/workspace_model.dart';
+import '../workspace/workspace_controller.dart';
+import 'markdown_pdf_export_service.dart';
+import 'markdown_pdf_models.dart';
+
+final markdownPdfExportServiceProvider = Provider(
+ (ref) => const MarkdownPdfExportService(),
+);
+
+bool canExportActiveMarkdown(WorkspaceState state) {
+ final workspace = state.workspace;
+ if (workspace == null ||
+ workspace.kind == WorkspaceKind.writersideModule ||
+ workspace.markdown == null) {
+ return false;
+ }
+ return switch (workspace.kind) {
+ WorkspaceKind.untitledMarkdown || WorkspaceKind.singleMarkdown => true,
+ WorkspaceKind.markdownFolder =>
+ workspace.activeFilePath != null &&
+ workspace.files.any(
+ (file) =>
+ file.absolutePath == workspace.activeFilePath &&
+ file.kind == DocumentKind.markdown,
+ ),
+ WorkspaceKind.writersideModule => false,
+ };
+}
+
+Future exportActiveMarkdownToPdf(
+ BuildContext context,
+ WidgetRef ref,
+) async {
+ final snapshot = ref.read(workspaceControllerProvider);
+ if (!canExportActiveMarkdown(snapshot)) {
+ return;
+ }
+ final workspace = snapshot.workspace!;
+ final headerBar = ref.read(linuxHeaderBarServiceProvider);
+ final options = await showBusyMarkModalDialog(
+ context,
+ headerBarService: headerBar.isAvailable ? headerBar : null,
+ builder: (context) => const _MarkdownPdfOptionsDialog(),
+ );
+ if (options == null || !context.mounted) {
+ return;
+ }
+
+ final activePath = workspace.activeFilePath ?? workspace.markdown?.filePath;
+ final baseName = activePath == null || activePath.isEmpty
+ ? context.l10n.untitledMarkdownFileName
+ : p.basename(activePath);
+ final location = await getSaveLocation(
+ acceptedTypeGroups: [
+ XTypeGroup(
+ label: context.l10n.fileTypePdf,
+ extensions: const ['pdf'],
+ mimeTypes: const ['application/pdf'],
+ ),
+ ],
+ suggestedName: '${p.basenameWithoutExtension(baseName)}.pdf',
+ initialDirectory: _initialDirectory(workspace, activePath),
+ confirmButtonText: context.l10n.export,
+ );
+ if (location == null || !context.mounted) {
+ return;
+ }
+
+ final destinationPath = _withPdfExtension(location.path);
+ final targetType = await FileSystemEntity.type(
+ destinationPath,
+ followLinks: false,
+ );
+ if (!context.mounted) {
+ return;
+ }
+ var overwrite = false;
+ if (targetType != FileSystemEntityType.notFound) {
+ final confirmed = await _confirmPdfOverwrite(
+ context,
+ headerBar,
+ destinationPath,
+ );
+ if (!confirmed || !context.mounted) {
+ return;
+ }
+ overwrite = true;
+ }
+ if (!context.mounted) {
+ return;
+ }
+
+ final cancellationToken = MarkdownPdfCancellationToken();
+ final request = MarkdownPdfExportRequest(
+ source: snapshot.activeText,
+ filePath: activePath ?? '',
+ workspaceRoot: workspace.rootPath,
+ destinationPath: destinationPath,
+ options: options,
+ overwrite: overwrite,
+ );
+ final outcome = await showBusyMarkModalDialog<_PdfExportOutcome>(
+ context,
+ headerBarService: headerBar.isAvailable ? headerBar : null,
+ barrierDismissible: false,
+ builder: (context) => _MarkdownPdfProgressDialog(
+ cancellationToken: cancellationToken,
+ operation: () => ref
+ .read(markdownPdfExportServiceProvider)
+ .export(request, cancellationToken: cancellationToken),
+ ),
+ );
+ if (outcome == null || !context.mounted) {
+ return;
+ }
+ final failure = outcome.failure;
+ if (failure != null) {
+ if (failure.code != MarkdownPdfFailureCode.cancelled) {
+ await _showPdfExportError(context, headerBar, failure);
+ }
+ return;
+ }
+ final result = outcome.result!;
+ final fileName = p.basename(result.destinationPath);
+ final message = result.warnings.isEmpty
+ ? context.l10n.pdfExported(fileName)
+ : context.l10n.pdfExportedWithWarnings(fileName, result.warnings.length);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(message),
+ action: SnackBarAction(
+ label: context.l10n.open,
+ onPressed: () => unawaited(
+ launchUrl(
+ Uri.file(result.destinationPath),
+ mode: LaunchMode.externalApplication,
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
+String? _initialDirectory(Workspace workspace, String? activePath) {
+ if (activePath != null && activePath.isNotEmpty) {
+ return p.dirname(activePath);
+ }
+ return workspace.rootPath.isEmpty ? null : workspace.rootPath;
+}
+
+String _withPdfExtension(String path) {
+ final normalized = p.normalize(path);
+ return p.extension(normalized).toLowerCase() == '.pdf'
+ ? normalized
+ : '$normalized.pdf';
+}
+
+Future _confirmPdfOverwrite(
+ BuildContext context,
+ LinuxHeaderBarService headerBar,
+ String path,
+) async {
+ return await showBusyMarkModalDialog(
+ context,
+ headerBarService: headerBar.isAvailable ? headerBar : null,
+ builder: (context) => BusyMarkDialogShell(
+ title: context.l10n.warning,
+ actions: [
+ BusyMarkDialogButton(
+ label: context.l10n.cancel,
+ onPressed: () => Navigator.pop(context, false),
+ ),
+ BusyMarkDialogButton(
+ label: context.l10n.overwrite,
+ destructive: true,
+ onPressed: () => Navigator.pop(context, true),
+ ),
+ ],
+ children: [Text(context.l10n.errorPathAlreadyExists(path))],
+ ),
+ ) ??
+ false;
+}
+
+Future _showPdfExportError(
+ BuildContext context,
+ LinuxHeaderBarService headerBar,
+ MarkdownPdfExportException failure,
+) {
+ final message = switch (failure.code) {
+ MarkdownPdfFailureCode.compilerUnavailable =>
+ context.l10n.pdfExportUnavailable,
+ MarkdownPdfFailureCode.timedOut => context.l10n.pdfExportTimedOut,
+ MarkdownPdfFailureCode.destinationExists =>
+ context.l10n.errorPathAlreadyExists(failure.detail),
+ MarkdownPdfFailureCode.compilerFailed ||
+ MarkdownPdfFailureCode.invalidOutput ||
+ MarkdownPdfFailureCode.fileSystem ||
+ MarkdownPdfFailureCode.cancelled => context.l10n.pdfExportFailed,
+ };
+ return showBusyMarkModalDialog(
+ context,
+ headerBarService: headerBar.isAvailable ? headerBar : null,
+ builder: (context) => BusyMarkDialogShell(
+ title: context.l10n.exportAsPdf,
+ actions: [
+ BusyMarkDialogButton(
+ label: MaterialLocalizations.of(context).okButtonLabel,
+ suggested: true,
+ onPressed: () => Navigator.pop(context),
+ ),
+ ],
+ children: [Text(message)],
+ ),
+ );
+}
+
+class _MarkdownPdfOptionsDialog extends StatefulWidget {
+ const _MarkdownPdfOptionsDialog();
+
+ @override
+ State<_MarkdownPdfOptionsDialog> createState() =>
+ _MarkdownPdfOptionsDialogState();
+}
+
+class _MarkdownPdfOptionsDialogState extends State<_MarkdownPdfOptionsDialog> {
+ var _options = const MarkdownPdfOptions();
+
+ @override
+ Widget build(BuildContext context) {
+ return BusyMarkDialogShell(
+ title: context.l10n.exportAsPdf,
+ maxWidth: 520,
+ actions: [
+ BusyMarkDialogButton(
+ label: context.l10n.cancel,
+ onPressed: () => Navigator.pop(context),
+ ),
+ BusyMarkDialogButton(
+ label: context.l10n.export,
+ icon: BusyMarkGlyphs.exportPdf,
+ suggested: true,
+ onPressed: () => Navigator.pop(context, _options),
+ ),
+ ],
+ children: [
+ Text(context.l10n.pdfExportDescription),
+ BusyMarkGroupedList(
+ filled: true,
+ children: [
+ BusyMarkComboRow(
+ title: context.l10n.pdfPageSize,
+ values: MarkdownPdfPageSize.values,
+ selected: _options.pageSize,
+ labelFor: (value) => switch (value) {
+ MarkdownPdfPageSize.a4 => context.l10n.pdfPageSizeA4,
+ MarkdownPdfPageSize.letter => context.l10n.pdfPageSizeLetter,
+ },
+ onSelected: (value) =>
+ setState(() => _options = _options.copyWith(pageSize: value)),
+ ),
+ BusyMarkComboRow(
+ title: context.l10n.pdfOrientation,
+ values: MarkdownPdfOrientation.values,
+ selected: _options.orientation,
+ labelFor: (value) => switch (value) {
+ MarkdownPdfOrientation.portrait => context.l10n.pdfPortrait,
+ MarkdownPdfOrientation.landscape => context.l10n.pdfLandscape,
+ },
+ onSelected: (value) => setState(
+ () => _options = _options.copyWith(orientation: value),
+ ),
+ ),
+ BusyMarkComboRow(
+ title: context.l10n.pdfMargins,
+ values: MarkdownPdfMargin.values,
+ selected: _options.margin,
+ labelFor: (value) => switch (value) {
+ MarkdownPdfMargin.narrow => context.l10n.pdfMarginNarrow,
+ MarkdownPdfMargin.normal => context.l10n.pdfMarginNormal,
+ MarkdownPdfMargin.wide => context.l10n.pdfMarginWide,
+ },
+ onSelected: (value) =>
+ setState(() => _options = _options.copyWith(margin: value)),
+ ),
+ BusyMarkSwitchRow(
+ title: context.l10n.pdfIncludePageNumbers,
+ value: _options.pageNumbers,
+ onChanged: (value) => setState(
+ () => _options = _options.copyWith(pageNumbers: value),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: BusyMarkSpacing.md),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Padding(
+ padding: EdgeInsetsDirectional.only(top: 2),
+ child: Icon(BusyMarkGlyphs.info, size: BusyMarkSizes.iconSm),
+ ),
+ const SizedBox(width: BusyMarkSpacing.sm),
+ Expanded(
+ child: Text(
+ context.l10n.pdfRemoteImagesNote,
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+}
+
+class _MarkdownPdfProgressDialog extends StatefulWidget {
+ const _MarkdownPdfProgressDialog({
+ required this.operation,
+ required this.cancellationToken,
+ });
+
+ final Future Function() operation;
+ final MarkdownPdfCancellationToken cancellationToken;
+
+ @override
+ State<_MarkdownPdfProgressDialog> createState() =>
+ _MarkdownPdfProgressDialogState();
+}
+
+class _MarkdownPdfProgressDialogState
+ extends State<_MarkdownPdfProgressDialog> {
+ var _cancelling = false;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) => _run());
+ }
+
+ Future _run() async {
+ try {
+ final result = await widget.operation();
+ if (mounted) {
+ Navigator.pop(context, _PdfExportOutcome.success(result));
+ }
+ } on MarkdownPdfExportException catch (failure) {
+ if (mounted) {
+ Navigator.pop(context, _PdfExportOutcome.failure(failure));
+ }
+ } on Object catch (error) {
+ if (mounted) {
+ Navigator.pop(
+ context,
+ _PdfExportOutcome.failure(
+ MarkdownPdfExportException(
+ MarkdownPdfFailureCode.fileSystem,
+ detail: error.toString(),
+ cause: error,
+ ),
+ ),
+ );
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return PopScope(
+ canPop: false,
+ child: BusyMarkDialogShell(
+ title: context.l10n.exportingPdf,
+ closable: false,
+ maxWidth: 420,
+ actions: [
+ BusyMarkDialogButton(
+ label: context.l10n.cancel,
+ onPressed: _cancelling
+ ? null
+ : () {
+ setState(() => _cancelling = true);
+ widget.cancellationToken.cancel();
+ },
+ ),
+ ],
+ children: const [
+ Center(child: CircularProgressIndicator()),
+ SizedBox(height: BusyMarkSpacing.md),
+ ],
+ ),
+ );
+ }
+}
+
+class _PdfExportOutcome {
+ const _PdfExportOutcome._({this.result, this.failure});
+
+ factory _PdfExportOutcome.success(MarkdownPdfExportResult result) {
+ return _PdfExportOutcome._(result: result);
+ }
+
+ factory _PdfExportOutcome.failure(MarkdownPdfExportException failure) {
+ return _PdfExportOutcome._(failure: failure);
+ }
+
+ final MarkdownPdfExportResult? result;
+ final MarkdownPdfExportException? failure;
+}
diff --git a/lib/src/export/markdown_pdf_models.dart b/lib/src/export/markdown_pdf_models.dart
new file mode 100644
index 0000000..2fc2bb0
--- /dev/null
+++ b/lib/src/export/markdown_pdf_models.dart
@@ -0,0 +1,155 @@
+import 'package:flutter/foundation.dart';
+
+enum MarkdownPdfPageSize { a4, letter }
+
+enum MarkdownPdfOrientation { portrait, landscape }
+
+enum MarkdownPdfMargin { narrow, normal, wide }
+
+@immutable
+class MarkdownPdfOptions {
+ const MarkdownPdfOptions({
+ this.pageSize = MarkdownPdfPageSize.a4,
+ this.orientation = MarkdownPdfOrientation.portrait,
+ this.margin = MarkdownPdfMargin.normal,
+ this.pageNumbers = true,
+ });
+
+ final MarkdownPdfPageSize pageSize;
+ final MarkdownPdfOrientation orientation;
+ final MarkdownPdfMargin margin;
+ final bool pageNumbers;
+
+ MarkdownPdfOptions copyWith({
+ MarkdownPdfPageSize? pageSize,
+ MarkdownPdfOrientation? orientation,
+ MarkdownPdfMargin? margin,
+ bool? pageNumbers,
+ }) {
+ return MarkdownPdfOptions(
+ pageSize: pageSize ?? this.pageSize,
+ orientation: orientation ?? this.orientation,
+ margin: margin ?? this.margin,
+ pageNumbers: pageNumbers ?? this.pageNumbers,
+ );
+ }
+
+ Map toJson() {
+ final (horizontalMargin, verticalMargin) = switch (margin) {
+ MarkdownPdfMargin.narrow => (36, 36),
+ MarkdownPdfMargin.normal => (57, 57),
+ MarkdownPdfMargin.wide => (78, 72),
+ };
+ return {
+ 'paper': pageSize == MarkdownPdfPageSize.a4 ? 'a4' : 'us-letter',
+ 'landscape': orientation == MarkdownPdfOrientation.landscape,
+ 'marginHorizontalPt': horizontalMargin,
+ 'marginVerticalPt': verticalMargin,
+ 'pageNumbers': pageNumbers,
+ };
+ }
+}
+
+enum MarkdownPdfWarningCode {
+ remoteImageSkipped,
+ imageNotFound,
+ imageUnsupported,
+ imageTooLarge,
+ imageLimitReached,
+ imageReadFailed,
+}
+
+@immutable
+class MarkdownPdfWarning {
+ const MarkdownPdfWarning(this.code, this.destination);
+
+ final MarkdownPdfWarningCode code;
+ final String destination;
+}
+
+@immutable
+class MarkdownPdfExportRequest {
+ const MarkdownPdfExportRequest({
+ required this.source,
+ required this.filePath,
+ required this.workspaceRoot,
+ required this.destinationPath,
+ required this.options,
+ required this.overwrite,
+ });
+
+ final String source;
+ final String filePath;
+ final String workspaceRoot;
+ final String destinationPath;
+ final MarkdownPdfOptions options;
+ final bool overwrite;
+}
+
+@immutable
+class MarkdownPdfExportResult {
+ const MarkdownPdfExportResult({
+ required this.destinationPath,
+ required this.pageCount,
+ required this.warnings,
+ });
+
+ final String destinationPath;
+ final int? pageCount;
+ final List warnings;
+}
+
+enum MarkdownPdfFailureCode {
+ compilerUnavailable,
+ compilerFailed,
+ timedOut,
+ cancelled,
+ invalidOutput,
+ destinationExists,
+ fileSystem,
+}
+
+class MarkdownPdfExportException implements Exception {
+ const MarkdownPdfExportException(this.code, {this.detail = '', this.cause});
+
+ final MarkdownPdfFailureCode code;
+ final String detail;
+ final Object? cause;
+
+ @override
+ String toString() => detail.isEmpty
+ ? 'Markdown PDF export failed: ${code.name}'
+ : 'Markdown PDF export failed: ${code.name}: $detail';
+}
+
+class MarkdownPdfCancellationToken {
+ bool _cancelled = false;
+ void Function()? _onCancel;
+
+ bool get isCancelled => _cancelled;
+
+ void cancel() {
+ if (_cancelled) {
+ return;
+ }
+ _cancelled = true;
+ _onCancel?.call();
+ }
+
+ void throwIfCancelled() {
+ if (_cancelled) {
+ throw const MarkdownPdfExportException(MarkdownPdfFailureCode.cancelled);
+ }
+ }
+
+ void attach(void Function() onCancel) {
+ _onCancel = onCancel;
+ if (_cancelled) {
+ onCancel();
+ }
+ }
+
+ void detach() {
+ _onCancel = null;
+ }
+}
diff --git a/lib/src/export/typst_compiler.dart b/lib/src/export/typst_compiler.dart
new file mode 100644
index 0000000..3455276
--- /dev/null
+++ b/lib/src/export/typst_compiler.dart
@@ -0,0 +1,183 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+
+import 'markdown_pdf_models.dart';
+
+const typstCompilerVersion = '0.15.1';
+
+class TypstCompilerLocator {
+ const TypstCompilerLocator({this.environment, this.resolvedExecutable});
+
+ final Map? environment;
+ final String? resolvedExecutable;
+
+ String? locate() {
+ final processEnvironment = environment ?? Platform.environment;
+ final candidates = [
+ if (processEnvironment['BUSYMARK_TYPST_PATH'] case final override?)
+ override,
+ if (processEnvironment['SNAP'] case final snapRoot?)
+ p.join(snapRoot, 'libexec', 'busymark', 'typst'),
+ p.join(
+ p.dirname(resolvedExecutable ?? Platform.resolvedExecutable),
+ 'libexec',
+ 'busymark',
+ 'typst',
+ ),
+ ];
+ for (final candidate in candidates) {
+ if (candidate.trim().isEmpty) {
+ continue;
+ }
+ try {
+ final file = File(p.normalize(p.absolute(candidate)));
+ final stat = file.statSync();
+ if (stat.type == FileSystemEntityType.file && stat.mode & 0x49 != 0) {
+ return file.path;
+ }
+ } on FileSystemException {
+ // Try the next deterministic bundle location.
+ }
+ }
+ return null;
+ }
+}
+
+class TypstProcessResult {
+ const TypstProcessResult({
+ required this.exitCode,
+ required this.stdout,
+ required this.stderr,
+ });
+
+ final int exitCode;
+ final String stdout;
+ final String stderr;
+}
+
+abstract class TypstCommandRunner {
+ Future compile({
+ required String executable,
+ required Directory workingDirectory,
+ required Duration timeout,
+ required MarkdownPdfCancellationToken cancellationToken,
+ });
+}
+
+class DartTypstCommandRunner implements TypstCommandRunner {
+ const DartTypstCommandRunner({this.maximumDiagnosticBytes = 64 * 1024});
+
+ final int maximumDiagnosticBytes;
+
+ @override
+ Future compile({
+ required String executable,
+ required Directory workingDirectory,
+ required Duration timeout,
+ required MarkdownPdfCancellationToken cancellationToken,
+ }) async {
+ cancellationToken.throwIfCancelled();
+ Process process;
+ try {
+ process = await Process.start(
+ executable,
+ const [
+ 'compile',
+ '--root',
+ '.',
+ '--format',
+ 'pdf',
+ '--pdf-standard',
+ '1.7',
+ '--creation-timestamp',
+ '0',
+ '--diagnostic-format',
+ 'short',
+ 'document.typ',
+ 'output.pdf',
+ ],
+ workingDirectory: workingDirectory.path,
+ environment: {
+ 'TYPST_PACKAGE_PATH': p.join(workingDirectory.path, 'packages'),
+ 'TYPST_PACKAGE_CACHE_PATH': p.join(workingDirectory.path, 'cache'),
+ },
+ includeParentEnvironment: true,
+ runInShell: false,
+ );
+ } on Object catch (error) {
+ throw MarkdownPdfExportException(
+ MarkdownPdfFailureCode.compilerUnavailable,
+ detail: error.toString(),
+ cause: error,
+ );
+ }
+
+ var cancelled = false;
+ var processExited = false;
+ final exitCodeFuture = process.exitCode.then((exitCode) {
+ processExited = true;
+ return exitCode;
+ });
+ cancellationToken.attach(() {
+ cancelled = true;
+ process.kill(ProcessSignal.sigterm);
+ unawaited(
+ Future.delayed(const Duration(milliseconds: 300), () {
+ if (!processExited) {
+ process.kill(ProcessSignal.sigkill);
+ }
+ }),
+ );
+ });
+ final stdoutFuture = _collectBounded(process.stdout);
+ final stderrFuture = _collectBounded(process.stderr);
+ try {
+ final exitCode = await exitCodeFuture.timeout(
+ timeout,
+ onTimeout: () {
+ process.kill(ProcessSignal.sigterm);
+ throw const MarkdownPdfExportException(
+ MarkdownPdfFailureCode.timedOut,
+ );
+ },
+ );
+ processExited = true;
+ if (cancelled || cancellationToken.isCancelled) {
+ throw const MarkdownPdfExportException(
+ MarkdownPdfFailureCode.cancelled,
+ );
+ }
+ return TypstProcessResult(
+ exitCode: exitCode,
+ stdout: await stdoutFuture,
+ stderr: await stderrFuture,
+ );
+ } on TimeoutException {
+ process.kill(ProcessSignal.sigkill);
+ throw const MarkdownPdfExportException(MarkdownPdfFailureCode.timedOut);
+ } on MarkdownPdfExportException catch (error) {
+ if (error.code == MarkdownPdfFailureCode.timedOut) {
+ await Future.delayed(const Duration(milliseconds: 200));
+ process.kill(ProcessSignal.sigkill);
+ }
+ rethrow;
+ } finally {
+ processExited = true;
+ cancellationToken.detach();
+ }
+ }
+
+ Future _collectBounded(Stream> stream) async {
+ final bytes = [];
+ await for (final chunk in stream) {
+ final remaining = maximumDiagnosticBytes - bytes.length;
+ if (remaining > 0) {
+ bytes.addAll(chunk.take(remaining));
+ }
+ }
+ return utf8.decode(bytes, allowMalformed: true);
+ }
+}
diff --git a/lib/src/export/typst_payload_builder.dart b/lib/src/export/typst_payload_builder.dart
new file mode 100644
index 0000000..dd25d94
--- /dev/null
+++ b/lib/src/export/typst_payload_builder.dart
@@ -0,0 +1,56 @@
+import 'markdown_export_document.dart';
+import 'markdown_pdf_models.dart';
+
+class TypstPayloadBuilder {
+ const TypstPayloadBuilder();
+
+ Map build({
+ required MarkdownExportDocument document,
+ required MarkdownPdfOptions options,
+ required Map assets,
+ }) {
+ return {
+ 'schemaVersion': 1,
+ 'metadata': document.metadata.toJson(),
+ 'options': options.toJson(),
+ 'blocks': [for (final block in document.blocks) _block(block, assets)],
+ };
+ }
+
+ Map _block(
+ MarkdownExportBlock block,
+ Map assets,
+ ) {
+ return {
+ 'kind': block.kind.name,
+ if (block.text.isNotEmpty) 'text': block.text,
+ if (block.inlines.isNotEmpty)
+ 'inlines': [
+ for (final inline in block.inlines) _inline(inline, assets),
+ ],
+ if (block.children.isNotEmpty)
+ 'children': [for (final child in block.children) _block(child, assets)],
+ ...block.attributes,
+ };
+ }
+
+ Map _inline(
+ MarkdownExportInline inline,
+ Map assets,
+ ) {
+ return {
+ 'kind': inline.kind.name,
+ if (inline.text.isNotEmpty) 'text': inline.text,
+ if (inline.kind == MarkdownExportInlineKind.link &&
+ inline.destination != null)
+ 'destination': inline.destination!,
+ if (inline.kind == MarkdownExportInlineKind.image)
+ 'asset': assets[inline.destination] ?? '',
+ if (inline.kind == MarkdownExportInlineKind.image) 'alt': inline.text,
+ if (inline.children.isNotEmpty)
+ 'children': [
+ for (final child in inline.children) _inline(child, assets),
+ ],
+ };
+ }
+}
diff --git a/lib/src/markdown/busymark_document.dart b/lib/src/markdown/busymark_document.dart
index f85fdff..32fe5c4 100644
--- a/lib/src/markdown/busymark_document.dart
+++ b/lib/src/markdown/busymark_document.dart
@@ -2,6 +2,9 @@ import '../core/diagnostic.dart';
import '../core/source_span.dart';
import 'markdown_model.dart';
+/// Marks an empty WYSIWYG paragraph that must remain a source blank line.
+const busyMarkPreserveEmptyParagraphAttribute = 'preserveEmptyParagraph';
+
class BusyDocument {
const BusyDocument({
required this.filePath,
diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart
index 673c612..ba2e87b 100644
--- a/lib/src/markdown/busymark_markdown_serializer.dart
+++ b/lib/src/markdown/busymark_markdown_serializer.dart
@@ -18,15 +18,16 @@ class BusyMarkMarkdownSerializer {
if (!_isSourceBackedBlock(block)) {
continue;
}
+ if (_isPreservedEmptyParagraph(block)) {
+ chunks.add('');
+ continue;
+ }
final source = serializeBlock(block);
if (source.trim().isNotEmpty) {
chunks.add(source.trimRight());
}
}
- if (chunks.isEmpty) {
- return '';
- }
- return '${chunks.join('\n\n')}\n';
+ return _joinDocumentChunks(chunks);
}
String serializeBlock(BusyBlock block) {
@@ -86,6 +87,13 @@ class BusyMarkMarkdownSerializer {
if (dirtyBlocks.any((block) => block.sourceSpan == null)) {
return null;
}
+ if (dirtyBlocks.any(
+ (block) =>
+ _isPreservedEmptyParagraph(block) ||
+ block.sourceSpan!.startOffset == block.sourceSpan!.endOffset,
+ )) {
+ return null;
+ }
final spannedBlocks = [
for (final block in document.blocks)
if (_isSourceBackedBlock(block) && block.sourceSpan != null) block,
@@ -202,6 +210,40 @@ class BusyMarkMarkdownSerializer {
return block.kind != BusyBlockKind.frontMatter && !block.isGenerated;
}
+ bool _isPreservedEmptyParagraph(BusyBlock block) {
+ return block.kind == BusyBlockKind.paragraph &&
+ block.plainText.isEmpty &&
+ block.attributes[busyMarkPreserveEmptyParagraphAttribute] == 'true';
+ }
+
+ String _joinDocumentChunks(List chunks) {
+ if (chunks.isEmpty) {
+ return '';
+ }
+ // Empty chunks are intentional WYSIWYG paragraphs. Each contributes one
+ // source line in addition to normal Markdown block separation.
+ final firstContentIndex = chunks.indexWhere((chunk) => chunk.isNotEmpty);
+ if (firstContentIndex == -1) {
+ return chunks.length <= 1 ? '' : '\n' * (chunks.length - 1);
+ }
+ final buffer = StringBuffer()
+ ..write('\n' * firstContentIndex)
+ ..write(chunks[firstContentIndex]);
+ var emptyParagraphs = 0;
+ for (final chunk in chunks.skip(firstContentIndex + 1)) {
+ if (chunk.isEmpty) {
+ emptyParagraphs += 1;
+ continue;
+ }
+ buffer
+ ..write('\n' * (2 + emptyParagraphs))
+ ..write(chunk);
+ emptyParagraphs = 0;
+ }
+ buffer.write('\n' * (1 + emptyParagraphs));
+ return buffer.toString();
+ }
+
bool _hasDirtyContent(BusyBlock block) {
return block.dirty || block.children.any(_hasDirtyContent);
}
diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart
index ad2d914..cf3de2b 100644
--- a/lib/src/platform/header_bar_configuration.dart
+++ b/lib/src/platform/header_bar_configuration.dart
@@ -31,6 +31,9 @@ class HeaderBarLabels {
required this.sidebarShortcut,
required this.back,
required this.save,
+ required this.exportPdf,
+ required this.exportPdfShortcut,
+ required this.exportPdfGtkAccelerator,
required this.settings,
required this.settingsShortcut,
required this.settingsGtkAccelerator,
@@ -65,6 +68,9 @@ class HeaderBarLabels {
final String sidebarShortcut;
final String back;
final String save;
+ final String exportPdf;
+ final String exportPdfShortcut;
+ final String exportPdfGtkAccelerator;
final String settings;
final String settingsShortcut;
final String settingsGtkAccelerator;
@@ -99,6 +105,9 @@ class HeaderBarLabels {
'sidebarShortcut': sidebarShortcut,
'back': back,
'save': save,
+ 'exportPdf': exportPdf,
+ 'exportPdfShortcut': exportPdfShortcut,
+ 'exportPdfGtkAccelerator': exportPdfGtkAccelerator,
'settings': settings,
'settingsShortcut': settingsShortcut,
'settingsGtkAccelerator': settingsGtkAccelerator,
@@ -273,6 +282,7 @@ class HeaderBarConfiguration {
required this.searchQuery,
required this.textDirection,
required this.canRefresh,
+ this.canExportPdf = false,
required this.documentControlsVisible,
required this.searchActive,
required this.searchVisible,
@@ -292,6 +302,7 @@ class HeaderBarConfiguration {
final String searchQuery;
final TextDirection textDirection;
final bool canRefresh;
+ final bool canExportPdf;
final bool documentControlsVisible;
final bool searchActive;
final bool searchVisible;
@@ -312,6 +323,7 @@ class HeaderBarConfiguration {
String? searchQuery,
TextDirection? textDirection,
bool? canRefresh,
+ bool? canExportPdf,
bool? documentControlsVisible,
bool? searchActive,
bool? searchVisible,
@@ -330,6 +342,7 @@ class HeaderBarConfiguration {
searchQuery: searchQuery ?? this.searchQuery,
textDirection: textDirection ?? this.textDirection,
canRefresh: canRefresh ?? this.canRefresh,
+ canExportPdf: canExportPdf ?? this.canExportPdf,
documentControlsVisible:
documentControlsVisible ?? this.documentControlsVisible,
searchActive: searchActive ?? this.searchActive,
@@ -351,6 +364,7 @@ class HeaderBarConfiguration {
'searchQuery': searchQuery,
'textDirection': textDirection == TextDirection.rtl ? 'rtl' : 'ltr',
'canRefresh': canRefresh,
+ 'canExportPdf': canExportPdf,
'documentControlsVisible': documentControlsVisible,
'searchActive': searchActive,
'searchVisible': searchVisible,
@@ -370,6 +384,7 @@ class HeaderBarConfiguration {
searchQuery == other.searchQuery &&
textDirection == other.textDirection &&
canRefresh == other.canRefresh &&
+ canExportPdf == other.canExportPdf &&
documentControlsVisible == other.documentControlsVisible &&
searchActive == other.searchActive &&
searchVisible == other.searchVisible &&
@@ -398,6 +413,7 @@ class HeaderBarConfiguration {
searchQuery,
textDirection,
canRefresh,
+ canExportPdf,
documentControlsVisible,
searchActive,
searchVisible,
diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart
index 5d6056f..3b07386 100644
--- a/lib/src/platform/linux_header_bar_service.dart
+++ b/lib/src/platform/linux_header_bar_service.dart
@@ -16,6 +16,7 @@ enum HeaderBarAction {
search,
refresh,
save,
+ exportPdf,
menu,
settings,
keyboardShortcuts,
@@ -149,6 +150,10 @@ class LinuxHeaderBarService extends ChangeNotifier {
return _invokeLegacy('setCanRefresh', value);
}
+ Future setCanExportPdf(bool value) {
+ return _invokeLegacy('setCanExportPdf', value);
+ }
+
Future setDocumentControlsVisible(bool value) {
return _invokeLegacy('setDocumentControlsVisible', value);
}
@@ -280,6 +285,7 @@ class LinuxHeaderBarService extends ChangeNotifier {
('setTitleRange', configuration.title),
('setViewMode', configuration.viewMode.name),
('setCanRefresh', configuration.canRefresh),
+ ('setCanExportPdf', configuration.canExportPdf),
('setDocumentControlsVisible', configuration.documentControlsVisible),
('setSearchVisible', configuration.searchVisible),
('setSidebarVisible', configuration.sidebarVisible),
@@ -379,6 +385,7 @@ class LinuxHeaderBarService extends ChangeNotifier {
'search' => HeaderBarAction.search,
'refresh' => HeaderBarAction.refresh,
'save' => HeaderBarAction.save,
+ 'exportPdf' => HeaderBarAction.exportPdf,
'menu' => HeaderBarAction.menu,
'settings' => HeaderBarAction.settings,
'keyboardShortcuts' => HeaderBarAction.keyboardShortcuts,
diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart
index 7d04d96..9566af4 100644
--- a/lib/src/workspace/presentation/settings_screen.dart
+++ b/lib/src/workspace/presentation/settings_screen.dart
@@ -304,6 +304,7 @@ class _SettingsScreenState extends ConsumerState {
case HeaderBarAction.search:
case HeaderBarAction.refresh:
case HeaderBarAction.save:
+ case HeaderBarAction.exportPdf:
case HeaderBarAction.menu:
case HeaderBarAction.viewModeEditor:
case HeaderBarAction.viewModeSource:
@@ -319,6 +320,8 @@ class _SettingsScreenState extends ConsumerState {
BusyMarkMainMenuAction action,
) {
switch (action) {
+ case BusyMarkMainMenuAction.exportPdf:
+ break;
case BusyMarkMainMenuAction.settings:
_selectPage(SettingsPage.appearance);
case BusyMarkMainMenuAction.keyboardShortcuts:
diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart
index 557d4ca..9def107 100644
--- a/lib/src/workspace/presentation/welcome_screen.dart
+++ b/lib/src/workspace/presentation/welcome_screen.dart
@@ -232,6 +232,7 @@ class _WelcomeScreenState extends ConsumerState {
case HeaderBarAction.search:
case HeaderBarAction.refresh:
case HeaderBarAction.save:
+ case HeaderBarAction.exportPdf:
case HeaderBarAction.menu:
case HeaderBarAction.viewModeEditor:
case HeaderBarAction.viewModeSource:
@@ -256,6 +257,8 @@ class _WelcomeScreenState extends ConsumerState {
BusyMarkMainMenuAction action,
) {
switch (action) {
+ case BusyMarkMainMenuAction.exportPdf:
+ break;
case BusyMarkMainMenuAction.settings:
context.go(settingsLocation(SettingsReturnTarget.welcome));
case BusyMarkMainMenuAction.keyboardShortcuts:
diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart
index 63f163a..e1400ab 100644
--- a/lib/src/workspace/presentation/workspace_screen.dart
+++ b/lib/src/workspace/presentation/workspace_screen.dart
@@ -28,8 +28,10 @@ import '../../core/uri_utils.dart';
import '../../editor/document_callout.dart';
import '../../editor/document_code_block.dart';
import '../../editor/document_layout.dart';
+import '../../editor/document_list_marker.dart';
import '../../editor/document_surface.dart';
import '../../editor/document_text_direction.dart';
+import '../../editor/document_thematic_break.dart';
import '../../editor/markdown_image_view.dart';
import '../../editor/source/source_controller.dart';
import '../../editor/source/source_document.dart';
@@ -37,6 +39,7 @@ import '../../editor/source/source_editor.dart';
import '../../editor/source/source_search.dart';
import '../../editor/wysiwyg/wysiwyg_editor.dart';
import '../../feedback/presentation/feedback_dialog.dart';
+import '../../export/markdown_pdf_export_ui.dart';
import '../../git/application/git_controller.dart';
import '../../git/domain/git_models.dart';
import '../../git/presentation/git_diff_viewer.dart';
@@ -365,12 +368,14 @@ class WorkspaceScreen extends ConsumerWidget {
? '*${_activeFileName(context, workspace)}'
: _activeFileName(context, workspace);
final hasSidebar = _hasWorkspaceSidebar(workspace);
+ final canExportPdf = canExportActiveMarkdown(state);
final headerConfiguration = HeaderBarConfigurationDefaults.of(context)
.copyWith(
title: busyMarkBidiIsolateFor(context, title),
viewMode: _headerBarViewMode(settings.documentViewMode),
searchQuery: searchState.query,
canRefresh: true,
+ canExportPdf: canExportPdf,
documentControlsVisible: true,
searchActive: searchState.active,
searchVisible: true,
@@ -535,6 +540,7 @@ class WorkspaceScreen extends ConsumerWidget {
settingsController.setDocumentViewMode(mode),
),
BusyMarkMainMenuButton(
+ canExportPdf: canExportPdf,
onSelected: (action) =>
_handleMainMenuAction(context, ref, action),
),
@@ -653,6 +659,8 @@ class WorkspaceScreen extends ConsumerWidget {
unawaited(_validateActiveAndShowProblems(context, ref));
case HeaderBarAction.save:
break;
+ case HeaderBarAction.exportPdf:
+ unawaited(exportActiveMarkdownToPdf(context, ref));
case HeaderBarAction.settings:
context.go(settingsLocation(SettingsReturnTarget.workspace));
case HeaderBarAction.keyboardShortcuts:
@@ -704,6 +712,8 @@ class WorkspaceScreen extends ConsumerWidget {
BusyMarkMainMenuAction action,
) {
switch (action) {
+ case BusyMarkMainMenuAction.exportPdf:
+ unawaited(exportActiveMarkdownToPdf(context, ref));
case BusyMarkMainMenuAction.settings:
context.go(settingsLocation(SettingsReturnTarget.workspace));
case BusyMarkMainMenuAction.keyboardShortcuts:
@@ -7811,7 +7821,7 @@ class _PreviewBlockView extends StatelessWidget {
style: _diffPreviewTextStyle(
context,
displayBlock,
- _headingStyle(context, displayBlock.level),
+ busyMarkDocumentHeadingTextStyle(context, displayBlock.level),
),
),
),
@@ -7830,13 +7840,8 @@ class _PreviewBlockView extends StatelessWidget {
block: displayBlock,
workspace: workspace,
),
- PreviewBlockKind.admonition => BusyMarkDocumentCallout(
- icon: _admonitionIcon(displayBlock.attributes['style']),
- backgroundColor: switch (displayBlock.attributes['style']) {
- 'warning' => colors.admonitionWarning,
- 'tip' => colors.admonitionTip,
- _ => colors.admonitionNote,
- },
+ PreviewBlockKind.admonition => BusyMarkDocumentAdmonition(
+ style: displayBlock.attributes['style'],
child: _PreviewInlineText(
block: displayBlock,
style: _diffPreviewTextStyle(context, displayBlock, null),
@@ -7851,9 +7856,11 @@ class _PreviewBlockView extends StatelessWidget {
child: Text(displayBlock.text),
),
PreviewBlockKind.list => Padding(
- padding: EdgeInsets.only(
- top: BusyMarkSpacing.xs,
- bottom: _listBottomSpacing(displayBlock),
+ padding: busyMarkDocumentListItemPadding(
+ listRunEnd: listRunEnd,
+ endsWithNestedList:
+ displayBlock.children.isNotEmpty &&
+ displayBlock.children.last.kind == PreviewBlockKind.list,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -7861,14 +7868,14 @@ class _PreviewBlockView extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- SizedBox(
- width: BusyMarkSizes.previewListMarkerWidth,
- child: Padding(
- padding: const EdgeInsets.only(
- top: BusyMarkSizes.previewListMarkerTopInset,
- ),
- child: _ListMarker(block: displayBlock),
- ),
+ BusyMarkDocumentListMarker(
+ ordered: displayBlock.attributes['ordered'] == 'true',
+ marker: displayBlock.attributes['marker'],
+ task: switch (displayBlock.attributes['task']) {
+ 'true' => true,
+ 'false' => false,
+ _ => null,
+ },
),
const SizedBox(width: BusyMarkSpacing.sm),
Expanded(
@@ -7882,8 +7889,7 @@ class _PreviewBlockView extends StatelessWidget {
if (displayBlock.children.isNotEmpty)
Padding(
padding: const EdgeInsetsDirectional.only(
- start:
- BusyMarkSizes.previewListMarkerWidth + BusyMarkSpacing.sm,
+ start: BusyMarkSizes.documentListIndent,
),
child: _previewChildBlocks(displayBlock.children, first: false),
),
@@ -7899,10 +7905,7 @@ class _PreviewBlockView extends StatelessWidget {
)
: _previewChildBlocks(displayBlock.children, first: true),
),
- PreviewBlockKind.thematicBreak => Padding(
- padding: const EdgeInsets.symmetric(vertical: BusyMarkSpacing.mdPlus),
- child: const _PreviewThematicBreak(),
- ),
+ PreviewBlockKind.thematicBreak => const BusyMarkDocumentThematicBreak(),
PreviewBlockKind.table => _PreviewTable(block: displayBlock),
PreviewBlockKind.container
when displayBlock.attributes['htmlTag'] == 'figure' =>
@@ -8009,17 +8012,6 @@ class _PreviewBlockView extends StatelessWidget {
};
}
- double _listBottomSpacing(PreviewBlock displayBlock) {
- if (!listRunEnd) {
- return BusyMarkSpacing.xs;
- }
- if (displayBlock.children.isNotEmpty &&
- displayBlock.children.last.kind == PreviewBlockKind.list) {
- return BusyMarkSpacing.xs;
- }
- return BusyMarkSpacing.md;
- }
-
bool _isLastListBlock(List blocks, int index) {
return blocks[index].kind == PreviewBlockKind.list &&
(index == blocks.length - 1 ||
@@ -8086,23 +8078,6 @@ class _PreviewBlockView extends StatelessWidget {
sourceEndOffset: block.sourceEndOffset,
);
}
-
- TextStyle? _headingStyle(BuildContext context, int? level) {
- final theme = Theme.of(context).textTheme;
- return switch (level ?? 2) {
- 1 => theme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
- 2 => theme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
- _ => theme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
- };
- }
-
- IconData _admonitionIcon(String? style) {
- return switch (style) {
- 'warning' => BusyMarkGlyphs.warning,
- 'tip' => BusyMarkGlyphs.tip,
- _ => BusyMarkGlyphs.info,
- };
- }
}
TextDirection _previewBlockTextDirection(
@@ -8163,13 +8138,14 @@ class _PreviewTable extends StatelessWidget {
children: [
for (var index = 0; index < columnCount; index += 1)
Padding(
- padding: BusyMarkInsets.previewTableCell,
+ padding: BusyMarkInsets.documentTableCell,
child: index < row.children.length
? _PreviewInlineText(
block: row.children[index],
style: row.attributes['header'] == 'true'
- ? Theme.of(context).textTheme.bodyMedium
- ?.copyWith(fontWeight: FontWeight.w700)
+ ? busyMarkDocumentBodyTextStyle(
+ context,
+ ).copyWith(fontWeight: FontWeight.w700)
: null,
)
: const SizedBox.shrink(),
@@ -8183,26 +8159,6 @@ class _PreviewTable extends StatelessWidget {
}
}
-class _PreviewThematicBreak extends StatelessWidget {
- const _PreviewThematicBreak();
-
- @override
- Widget build(BuildContext context) {
- final colors = BusyMarkSurfaceColors.of(context);
- return Center(
- child: Container(
- height: BusyMarkTypography.previewThematicBreakHeight,
- decoration: BoxDecoration(
- color: colors.mutedForeground.withValues(
- alpha: BusyMarkAlpha.thematicBreak,
- ),
- borderRadius: BorderRadius.circular(BusyMarkRadius.pill),
- ),
- ),
- );
- }
-}
-
class _PreviewInlineText extends ConsumerWidget {
const _PreviewInlineText({super.key, required this.block, this.style});
@@ -8444,46 +8400,6 @@ double _previewBlockTargetFraction(
return 0.0;
}
-class _ListMarker extends StatelessWidget {
- const _ListMarker({required this.block});
-
- final PreviewBlock block;
-
- @override
- Widget build(BuildContext context) {
- final colors = BusyMarkSurfaceColors.of(context);
- final task = block.attributes['task'];
- if (task != null) {
- return Icon(
- task == 'true' ? BusyMarkGlyphs.checkedBox : BusyMarkGlyphs.task,
- size: BusyMarkSizes.iconSm,
- color: colors.mutedForeground,
- );
- }
- if (block.attributes['ordered'] == 'true') {
- return Text(
- block.attributes['marker'] ?? '1.',
- textAlign: TextAlign.end,
- style: Theme.of(
- context,
- ).textTheme.labelSmall?.copyWith(color: colors.mutedForeground),
- );
- }
- return Padding(
- padding: const EdgeInsets.only(top: BusyMarkSizes.listMarkerTopInset),
- child: SizedBox.square(
- dimension: BusyMarkSizes.markerDot,
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: colors.mutedForeground,
- shape: BoxShape.circle,
- ),
- ),
- ),
- );
- }
-}
-
class _PreviewFigure extends StatelessWidget {
const _PreviewFigure({
required this.block,
@@ -8573,12 +8489,12 @@ class _PreviewFigure extends StatelessWidget {
if (child.kind != PreviewBlockKind.image) {
continue;
}
- final width = _previewImageWidth(child);
+ final width = busyMarkDocumentImageWidth(child.attributes);
if (width != null) {
return math.max(width, _captionMinWidth);
}
}
- return BusyMarkSizes.previewImageMaxWidth;
+ return BusyMarkSizes.documentImageMaxWidth;
}
bool _isLastListBlock(List blocks, int index) {
@@ -8600,7 +8516,7 @@ class _PreviewImageBlock extends ConsumerWidget {
const _PreviewImageBlock({
required this.block,
required this.workspace,
- this.padding = const EdgeInsets.symmetric(vertical: BusyMarkSpacing.smPlus),
+ this.padding = BusyMarkInsets.documentImageBlock,
});
final PreviewBlock block;
@@ -8609,7 +8525,7 @@ class _PreviewImageBlock extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final width = _previewImageWidth(block);
+ final width = busyMarkDocumentImageWidth(block.attributes);
final source = _previewImageSource(block);
final activeFilePath =
workspace?.activeFilePath ?? workspace?.markdown?.filePath;
@@ -8619,20 +8535,26 @@ class _PreviewImageBlock extends ConsumerWidget {
);
return Padding(
padding: padding,
- child: Align(
- alignment: AlignmentDirectional.centerStart,
- child: MarkdownImageView(
- source: source,
- alt: block.text,
- activeFilePath: activeFilePath ?? '',
- workspaceRoot: _imageWorkspaceRoot(workspace),
- writersideRoot: workspace?.writersideModule?.rootPath,
- imagesDir: workspace?.writersideModule?.config.imagesDir ?? 'images',
- allowRemoteImages: allowRemoteImages,
- onRemoteImageBlocked: () =>
- unawaited(_showRemoteImagesPrompt(context, ref)),
- width: width,
- maxWidth: width ?? BusyMarkSizes.previewImageMaxWidth,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ minHeight: BusyMarkSizes.documentImageMinHeight,
+ ),
+ child: Align(
+ alignment: AlignmentDirectional.centerStart,
+ child: MarkdownImageView(
+ source: source,
+ alt: block.text,
+ activeFilePath: activeFilePath ?? '',
+ workspaceRoot: _imageWorkspaceRoot(workspace),
+ writersideRoot: workspace?.writersideModule?.rootPath,
+ imagesDir:
+ workspace?.writersideModule?.config.imagesDir ?? 'images',
+ allowRemoteImages: allowRemoteImages,
+ onRemoteImageBlocked: () =>
+ unawaited(_showRemoteImagesPrompt(context, ref)),
+ width: width,
+ maxWidth: width ?? BusyMarkSizes.documentImageMaxWidth,
+ ),
),
),
);
@@ -8687,23 +8609,6 @@ String? _previewImageSourceFromInline(PreviewInline inline) {
return null;
}
-double? _previewImageWidth(PreviewBlock block) {
- final value = block.attributes['width'];
- if (value == null) {
- return null;
- }
- final parsed = double.tryParse(value.replaceAll(RegExp('[^0-9.]'), ''));
- if (parsed == null || parsed <= 0) {
- return null;
- }
- return parsed
- .clamp(
- BusyMarkSizes.previewImageMinWidth,
- BusyMarkSizes.previewImageMaxWidth,
- )
- .toDouble();
-}
-
InlineSpan _previewInlineSpan(
BuildContext context,
PreviewInline inline, {
diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt
index c25b538..99446c7 100644
--- a/linux/CMakeLists.txt
+++ b/linux/CMakeLists.txt
@@ -61,6 +61,37 @@ add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
+# BusyMark bundles a pinned static Typst compiler for deterministic PDF export.
+# The preparation script verifies the upstream release checksum before the
+# compiler becomes part of the application bundle.
+set(TYPST_BUNDLE_DIR "${CMAKE_BINARY_DIR}/typst/linux-${CMAKE_SYSTEM_PROCESSOR}")
+set(TYPST_EXECUTABLE "${TYPST_BUNDLE_DIR}/typst")
+set(TYPST_LICENSE "${TYPST_BUNDLE_DIR}/LICENSE")
+set(TYPST_NOTICE "${TYPST_BUNDLE_DIR}/NOTICE")
+set(TYPST_VERSION_FILE "${TYPST_BUNDLE_DIR}/VERSION")
+add_custom_command(
+ OUTPUT
+ "${TYPST_EXECUTABLE}"
+ "${TYPST_LICENSE}"
+ "${TYPST_NOTICE}"
+ "${TYPST_VERSION_FILE}"
+ COMMAND
+ "${CMAKE_CURRENT_SOURCE_DIR}/../tools/fetch_typst.sh"
+ "${TYPST_BUNDLE_DIR}"
+ "${CMAKE_SYSTEM_PROCESSOR}"
+ DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/../tools/fetch_typst.sh"
+ COMMENT "Preparing pinned Typst compiler for PDF export"
+ VERBATIM
+)
+add_custom_target(busymark_typst ALL
+ DEPENDS
+ "${TYPST_EXECUTABLE}"
+ "${TYPST_LICENSE}"
+ "${TYPST_NOTICE}"
+ "${TYPST_VERSION_FILE}"
+)
+add_dependencies(${BINARY_NAME} busymark_typst)
+
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
@@ -92,6 +123,14 @@ install(CODE "
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
+install(PROGRAMS "${TYPST_EXECUTABLE}"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/libexec/busymark"
+ COMPONENT Runtime)
+
+install(FILES "${TYPST_LICENSE}" "${TYPST_NOTICE}" "${TYPST_VERSION_FILE}"
+ DESTINATION "${CMAKE_INSTALL_PREFIX}/share/licenses/typst"
+ COMPONENT Runtime)
+
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml
index 318b33d..92fbe73 100644
--- a/linux/io.busystack.busymark.metainfo.xml
+++ b/linux/io.busystack.busymark.metainfo.xml
@@ -63,7 +63,7 @@
https://github.com/busystack/busymark/issues
-
+
diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc
index 5059cac..df684c8 100644
--- a/linux/runner/my_application.cc
+++ b/linux/runner/my_application.cc
@@ -142,6 +142,7 @@ struct HeaderBarConfiguration {
const gchar* title;
const gchar* view_mode;
gboolean can_refresh;
+ gboolean can_export_pdf;
gboolean document_controls_visible;
gboolean search_active;
gboolean search_visible;
@@ -1256,6 +1257,9 @@ static GtkWidget* create_header_toggle_button(const gchar* icon_name) {
}
static const gchar* main_menu_icon_name(const gchar* action) {
+ if (g_strcmp0(action, "exportPdf") == 0) {
+ return "document-save-as-symbolic";
+ }
if (g_strcmp0(action, "settings") == 0) {
return "preferences-system-symbolic";
}
@@ -1304,6 +1308,11 @@ static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) {
return;
}
g_menu_remove_all(self->main_menu_model);
+ append_action_menu_item(
+ self->main_menu_model,
+ localized_label_or(labels, "exportPdf", ""), "header.export-pdf",
+ main_menu_icon_name("exportPdf"),
+ fl_lookup_string_arg(labels, "exportPdfGtkAccelerator"));
append_action_menu_item(
self->main_menu_model,
localized_label_or(labels, "settings", ""), "header.settings",
@@ -1435,8 +1444,23 @@ static void add_header_gaction(MyApplication* self,
g_object_unref(action);
}
+static void set_header_action_enabled(MyApplication* self,
+ const gchar* action_name,
+ gboolean enabled) {
+ if (self->header_action_group == nullptr) {
+ return;
+ }
+ GAction* action = g_action_map_lookup_action(
+ G_ACTION_MAP(self->header_action_group), action_name);
+ if (action != nullptr && G_IS_SIMPLE_ACTION(action)) {
+ g_simple_action_set_enabled(G_SIMPLE_ACTION(action), enabled);
+ }
+}
+
static void setup_header_actions(MyApplication* self) {
self->header_action_group = g_simple_action_group_new();
+ add_header_gaction(self, "export-pdf", "exportPdf");
+ set_header_action_enabled(self, "export-pdf", FALSE);
add_header_gaction(self, "settings", "settings");
add_header_gaction(self, "keyboard-shortcuts", "keyboardShortcuts");
add_header_gaction(self, "markdown-and-html", "markdownAndHtml");
@@ -1744,6 +1768,8 @@ static gboolean decode_header_bar_configuration(
configuration->sidebar_width <= 0 ||
!fl_lookup_optional_bool_arg(args, "canRefresh",
&configuration->can_refresh) ||
+ !fl_lookup_optional_bool_arg(args, "canExportPdf",
+ &configuration->can_export_pdf) ||
!fl_lookup_optional_bool_arg(
args, "documentControlsVisible",
&configuration->document_controls_visible) ||
@@ -1786,6 +1812,8 @@ static void apply_header_bar_configuration(
gtk_label_set_text(GTK_LABEL(self->title_label), configuration.title);
}
set_widget_sensitive(self->refresh_button, configuration.can_refresh);
+ set_header_action_enabled(self, "export-pdf",
+ configuration.can_export_pdf);
set_sidebar_width(self, configuration.sidebar_width);
set_text_direction(self, configuration.text_direction);
set_sidebar_visible(self, configuration.sidebar_visible);
@@ -2022,6 +2050,9 @@ static void header_bar_method_call_cb(FlMethodChannel* channel,
} else if (strcmp(method, "setCanRefresh") == 0) {
set_widget_sensitive(self->refresh_button, fl_method_bool_arg(args));
respond_success(method_call);
+ } else if (strcmp(method, "setCanExportPdf") == 0) {
+ set_header_action_enabled(self, "export-pdf", fl_method_bool_arg(args));
+ respond_success(method_call);
} else if (strcmp(method, "setDocumentControlsVisible") == 0) {
set_document_controls_visible(self, fl_method_bool_arg(args));
respond_success(method_call);
diff --git a/pubspec.yaml b/pubspec.yaml
index aad88ef..9388c79 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: busymark
description: Local-first Markdown and Writerside-compatible documentation editor.
publish_to: 'none'
-version: 0.2.3
+version: 0.2.4
environment:
sdk: ^3.12.1
@@ -43,3 +43,4 @@ flutter:
uses-material-design: true
assets:
- assets/branding/busymark_logo.svg
+ - assets/export/markdown.typ
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index 2c2bf47..e06c8de 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -1,6 +1,6 @@
name: busymark
title: BusyMark
-version: "0.2.3"
+version: "0.2.4"
summary: Markdown and Writerside documentation editor
# Snap Store listing translations are managed outside this Flutter package.
# Update store metadata when approved translated listing text is supplied.
@@ -42,6 +42,7 @@ apps:
- ssh-keys
environment:
GDK_BACKEND: wayland,x11
+ TYPST_FONT_PATHS: $SNAP/usr/share/fonts
XDG_CACHE_HOME: $SNAP_USER_DATA/.cache
XDG_CONFIG_HOME: $SNAP_USER_DATA/.config
XDG_DATA_HOME: $SNAP_USER_DATA/.local/share
@@ -53,7 +54,9 @@ parts:
flutter-target: lib/main.dart
flutter-channel: stable
build-packages:
+ - curl
- libhandy-1-dev
+ - xz-utils
stage-packages:
- libhandy-1-0
- libx11-6
diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart
index b2a8852..7c5d00b 100644
--- a/test/src/app_smoke_test.dart
+++ b/test/src/app_smoke_test.dart
@@ -23,6 +23,8 @@ import 'package:busymark/src/core/source_span.dart';
import 'package:busymark/src/editor/document_callout.dart';
import 'package:busymark/src/editor/document_code_block.dart';
import 'package:busymark/src/editor/document_layout.dart';
+import 'package:busymark/src/editor/document_list_marker.dart';
+import 'package:busymark/src/editor/document_thematic_break.dart';
import 'package:busymark/src/editor/markdown_image_view.dart';
import 'package:busymark/src/editor/source/source_editor.dart';
import 'package:busymark/src/editor/source/source_read_only_view.dart';
@@ -3605,6 +3607,212 @@ void main() {
expect(previewStyle?.height, editorStyle?.height);
});
+ testWidgets('Editor and Preview share heading and thematic-break geometry', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1400, 900);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ final settingsStore = _MemorySettingsStore()
+ ..value = AppSettings.defaults()
+ .copyWith(documentViewMode: DocumentViewModePreference.editor)
+ .toJson();
+ const headings = ['Third', 'Fourth', 'Fifth', 'Sixth'];
+ const service = _SearchWorkspaceService('''
+### Third
+
+#### Fourth
+
+##### Fifth
+
+###### Sixth
+
+Before break.
+
+---
+
+After break.
+''');
+ final container = ProviderContainer(
+ overrides: [
+ linuxHeaderBarServiceProvider.overrideWithValue(headerBarService),
+ localSettingsStoreProvider.overrideWithValue(settingsStore),
+ workspaceServiceProvider.overrideWithValue(service),
+ startupPathProvider.overrideWithValue('/tmp/shared-block-geometry.md'),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: container,
+ child: const BusyMarkApp(),
+ ),
+ );
+ for (var i = 0; i < 30; i += 1) {
+ await tester.pump(const Duration(milliseconds: 100));
+ if (find.byType(BusyMarkDocumentThematicBreak).evaluate().isNotEmpty) {
+ break;
+ }
+ }
+
+ Finder editorHeading(String text) => find.byWidgetPredicate(
+ (widget) => widget is TextField && widget.controller?.text == text,
+ );
+ final editorHeadingRects = {};
+ final editorHeadingStyles = {};
+ for (final heading in headings) {
+ final finder = editorHeading(heading);
+ expect(finder, findsOneWidget);
+ editorHeadingRects[heading] = tester.getRect(finder);
+ editorHeadingStyles[heading] = tester.widget(finder).style;
+ }
+ final editorBreak = find.byType(BusyMarkDocumentThematicBreak);
+ expect(editorBreak, findsOneWidget);
+ expect(
+ tester.widget(editorBreak).editable,
+ isTrue,
+ );
+ final editorBreakRect = tester.getRect(editorBreak);
+ final editorAfterRect = _rightmostTextRect(tester, 'After break.');
+ expect(
+ editorBreakRect.height,
+ closeTo(
+ BusyMarkInsets.documentThematicBreakBlock.vertical +
+ BusyMarkStroke.thematicBreak,
+ 0.01,
+ ),
+ );
+
+ await container
+ .read(appSettingsControllerProvider.notifier)
+ .setDocumentViewMode(DocumentViewModePreference.preview);
+ await tester.pump(const Duration(milliseconds: 100));
+
+ final previewContent = find.byKey(
+ const ValueKey('preview-document-content'),
+ );
+ Finder previewHeading(String text) => find.descendant(
+ of: previewContent,
+ matching: find.byWidgetPredicate(
+ (widget) => widget is Text && widget.textSpan?.toPlainText() == text,
+ ),
+ );
+ for (final heading in headings) {
+ final finder = previewHeading(heading);
+ expect(finder, findsOneWidget);
+ final previewRect = tester.getRect(finder);
+ final previewStyle = tester.widget(finder).textSpan?.style;
+ final editorRect = editorHeadingRects[heading]!;
+ final editorStyle = editorHeadingStyles[heading];
+ expect(previewRect.left, closeTo(editorRect.left, 0.1));
+ expect(previewRect.top, closeTo(editorRect.top, 0.1));
+ expect(previewRect.height, closeTo(editorRect.height, 0.1));
+ expect(previewStyle?.fontSize, editorStyle?.fontSize);
+ expect(previewStyle?.fontWeight, editorStyle?.fontWeight);
+ expect(previewStyle?.height, editorStyle?.height);
+ }
+
+ final previewBreak = find.byType(BusyMarkDocumentThematicBreak);
+ expect(previewBreak, findsOneWidget);
+ expect(
+ tester.widget(previewBreak).editable,
+ isFalse,
+ );
+ final previewBreakRect = tester.getRect(previewBreak);
+ final previewAfterRect = _rightmostTextRect(tester, 'After break.');
+ expect(previewBreakRect, editorBreakRect);
+ expect(previewAfterRect.left, closeTo(editorAfterRect.left, 0.1));
+ expect(previewAfterRect.top, closeTo(editorAfterRect.top, 0.1));
+ });
+
+ testWidgets('Editor and Preview share admonition and image presentation', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1400, 800);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ final settingsStore = _MemorySettingsStore()
+ ..value = AppSettings.defaults()
+ .copyWith(documentViewMode: DocumentViewModePreference.editor)
+ .toJson();
+ const service = _SearchWorkspaceService('''
+Shared warning.
+
+{ width="320" }
+''');
+ final container = ProviderContainer(
+ overrides: [
+ linuxHeaderBarServiceProvider.overrideWithValue(headerBarService),
+ localSettingsStoreProvider.overrideWithValue(settingsStore),
+ workspaceServiceProvider.overrideWithValue(service),
+ startupPathProvider.overrideWithValue('/tmp/shared-rich-blocks.md'),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: container,
+ child: const BusyMarkApp(),
+ ),
+ );
+ for (var i = 0; i < 30; i += 1) {
+ await tester.pump(const Duration(milliseconds: 100));
+ if (find.byType(BusyMarkDocumentAdmonition).evaluate().isNotEmpty &&
+ find.byType(MarkdownImageView).evaluate().isNotEmpty) {
+ break;
+ }
+ }
+
+ final editorAdmonition = find.byType(BusyMarkDocumentAdmonition);
+ final editorImage = find.byType(MarkdownImageView);
+ expect(editorAdmonition, findsOneWidget);
+ expect(editorImage, findsOneWidget);
+ expect(
+ find.descendant(
+ of: editorAdmonition,
+ matching: find.byIcon(BusyMarkGlyphs.warning),
+ ),
+ findsOneWidget,
+ );
+ final editorAdmonitionRect = tester.getRect(editorAdmonition);
+ final editorImageRect = tester.getRect(editorImage);
+ final editorImageWidget = tester.widget(editorImage);
+ expect(editorImageWidget.width, 320);
+ expect(editorImageWidget.maxWidth, 320);
+
+ await container
+ .read(appSettingsControllerProvider.notifier)
+ .setDocumentViewMode(DocumentViewModePreference.preview);
+ await tester.pump(const Duration(milliseconds: 100));
+
+ final previewAdmonition = find.byType(BusyMarkDocumentAdmonition);
+ final previewImage = find.byType(MarkdownImageView);
+ expect(previewAdmonition, findsOneWidget);
+ expect(previewImage, findsOneWidget);
+ expect(
+ find.descendant(
+ of: previewAdmonition,
+ matching: find.byIcon(BusyMarkGlyphs.warning),
+ ),
+ findsOneWidget,
+ );
+ final previewImageWidget = tester.widget(previewImage);
+ expect(previewImageWidget.width, editorImageWidget.width);
+ expect(previewImageWidget.maxWidth, editorImageWidget.maxWidth);
+ expect(tester.getRect(previewAdmonition), editorAdmonitionRect);
+ expect(tester.getRect(previewImage), editorImageRect);
+ });
+
testWidgets('Preview renders structured formatted and nested quotes', (
tester,
) async {
@@ -3813,7 +4021,9 @@ void main() {
expect(service.savedText, '# Edited Introduction\n');
});
- testWidgets('Tab inserts a tab character in editor view', (tester) async {
+ testWidgets('Tab inserts a tab character in editor view paragraphs', (
+ tester,
+ ) async {
final settingsStore = _MemorySettingsStore()
..value = AppSettings.defaults()
.copyWith(
@@ -3821,7 +4031,7 @@ void main() {
editorToolbarPlacement: EditorToolbarPlacement.bottomLeft,
)
.toJson();
- final service = _SearchWorkspaceService('- Item\n');
+ final service = _SearchWorkspaceService('Paragraph\n');
final container = ProviderContainer(
overrides: [
linuxHeaderBarServiceProvider.overrideWithValue(headerBarService),
@@ -3861,10 +4071,10 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
- expect(controller.text, '\tItem');
+ expect(controller.text, '\tParagraph');
expect(
container.read(workspaceControllerProvider).activeText,
- '- \tItem\n',
+ '\tParagraph\n',
);
await tester.pump(const Duration(seconds: 2));
await tester.pump();
@@ -5240,7 +5450,9 @@ Draft paragraph.
expect(rect.bottom, lessThan(800), reason: 'rect=$rect offset=$offset');
});
- testWidgets('preview adds extra vertical space after a list', (tester) async {
+ testWidgets('Editor and Preview share list indentation and run spacing', (
+ tester,
+ ) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(() {
@@ -5250,7 +5462,7 @@ Draft paragraph.
final settingsStore = _MemorySettingsStore()
..value = AppSettings.defaults()
- .copyWith(documentViewMode: DocumentViewModePreference.preview)
+ .copyWith(documentViewMode: DocumentViewModePreference.editor)
.toJson();
final service = _SearchWorkspaceService(
'# Title\n\n'
@@ -5277,18 +5489,43 @@ Draft paragraph.
);
for (var i = 0; i < 20; i += 1) {
await tester.pump(const Duration(milliseconds: 100));
- if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) {
+ if (find.byType(BusyMarkDocumentListMarker).evaluate().length == 2) {
break;
}
}
- final first = _rightmostTextRect(tester, 'First item');
- final second = _rightmostTextRect(tester, 'Second item');
- final after = _rightmostTextRect(tester, 'After list paragraph.');
- final itemGap = second.top - first.bottom;
- final afterListGap = after.top - second.bottom;
+ final editorFirst = _rightmostTextRect(tester, 'First item');
+ final editorSecond = _rightmostTextRect(tester, 'Second item');
+ final editorAfter = _rightmostTextRect(tester, 'After list paragraph.');
+ final editorMarkers = find.byType(BusyMarkDocumentListMarker);
+ expect(editorMarkers, findsNWidgets(2));
+ final editorMarkerRect = tester.getRect(editorMarkers.first);
+ final editorItemGap = editorSecond.top - editorFirst.bottom;
+ final editorAfterListGap = editorAfter.top - editorSecond.bottom;
+
+ expect(editorAfterListGap, greaterThan(editorItemGap + BusyMarkSpacing.xs));
+
+ await container
+ .read(appSettingsControllerProvider.notifier)
+ .setDocumentViewMode(DocumentViewModePreference.preview);
+ await tester.pump(const Duration(milliseconds: 100));
- expect(afterListGap, greaterThan(itemGap + BusyMarkSpacing.xs));
+ final previewFirst = _rightmostTextRect(tester, 'First item');
+ final previewSecond = _rightmostTextRect(tester, 'Second item');
+ final previewAfter = _rightmostTextRect(tester, 'After list paragraph.');
+ final previewMarkers = find.byType(BusyMarkDocumentListMarker);
+ expect(previewMarkers, findsNWidgets(2));
+ final previewMarkerRect = tester.getRect(previewMarkers.first);
+ final previewItemGap = previewSecond.top - previewFirst.bottom;
+ final previewAfterListGap = previewAfter.top - previewSecond.bottom;
+
+ expect(previewFirst.left, closeTo(editorFirst.left, 0.1));
+ expect(previewSecond.left, closeTo(editorSecond.left, 0.1));
+ expect(previewAfter.left, closeTo(editorAfter.left, 0.1));
+ expect(previewMarkerRect.left, closeTo(editorMarkerRect.left, 0.1));
+ expect(previewMarkerRect.width, editorMarkerRect.width);
+ expect(previewItemGap, closeTo(editorItemGap, 0.1));
+ expect(previewAfterListGap, closeTo(editorAfterListGap, 0.1));
});
testWidgets('preview renders nested list children', (tester) async {
diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart
index 21ad8e9..b6b45cd 100644
--- a/test/src/busymark_document_test.dart
+++ b/test/src/busymark_document_test.dart
@@ -52,6 +52,20 @@ void main() {
);
});
+ test('document image width resolution is bounded consistently', () {
+ expect(busyMarkDocumentImageWidth(const {}), isNull);
+ expect(busyMarkDocumentImageWidth(const {'width': 'invalid'}), isNull);
+ expect(
+ busyMarkDocumentImageWidth(const {'width': '40px'}),
+ BusyMarkSizes.documentImageMinWidth,
+ );
+ expect(busyMarkDocumentImageWidth(const {'width': '320'}), 320);
+ expect(
+ busyMarkDocumentImageWidth(const {'width': '1200px'}),
+ BusyMarkSizes.documentImageMaxWidth,
+ );
+ });
+
test('package markdown AST imports into BusyDocument core blocks', () {
final parsed = parser.parse(
filePath: 'topic.md',
@@ -1308,7 +1322,7 @@ void main() {}
expect(nestedRect.left, closeTo(parentRect.left, 0.1));
expect(
nestedRect.right,
- closeTo(parentRect.right - BusyMarkSizes.wysiwygBlockIndent, 0.1),
+ closeTo(parentRect.right - BusyMarkSizes.documentListIndent, 0.1),
);
});
@@ -1344,31 +1358,788 @@ void main() {}
),
),
),
- ),
- );
- await tester.pump();
+ ),
+ );
+ await tester.pump();
+
+ final field = find.byKey(const ValueKey('wysiwyg-field-topic.md-quote'));
+ expect(find.byType(BusyMarkDocumentCallout), findsOneWidget);
+ expect(field, findsOneWidget);
+ expect(tester.widget(field).controller!.text, 'Leaf quote');
+
+ await tester.enterText(field, 'Changed');
+ await tester.pump();
+
+ expect(markdown, '> Changed\n');
+ });
+
+ testWidgets('WYSIWYG hides a definition-only source and preserves it', (
+ tester,
+ ) async {
+ const source = '[guide]: docs.md "Title"\n';
+ final parsed = parser.parse(filePath: 'topic.md', source: source);
+ var markdown = source;
+
+ expect(
+ const BusyMarkPreviewBuilder().build(parsed.busyDocument).blocks,
+ isEmpty,
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: parsed.busyDocument,
+ onSourceChanged: (filePath, value) => markdown = value,
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(find.byType(TextField), findsOneWidget);
+ expect(find.textContaining('[guide]:'), findsNothing);
+
+ await tester.enterText(find.byType(TextField), 'First line');
+ await tester.pump();
+
+ expect(markdown, '[guide]: docs.md "Title"\n\nFirst line\n');
+ });
+
+ testWidgets('WYSIWYG editor undo and redo restore document edits', (
+ tester,
+ ) async {
+ final parsed = parser.parse(filePath: 'topic.md', source: 'Original\n');
+ var markdown = parsed.source;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: parsed.busyDocument,
+ onSourceChanged: (filePath, value) => markdown = value,
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ await tester.enterText(find.byType(TextField).first, 'Changed');
+ await tester.pump();
+
+ expect(markdown, 'Changed\n');
+ final editedController = tester
+ .widget(find.byType(TextField).first)
+ .controller!;
+ editedController.selection = const TextSelection.collapsed(offset: 2);
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.keyZ);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.keyZ);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
+ await tester.pump();
+
+ expect(markdown, 'Original\n');
+ expect(
+ tester.widget(find.byType(TextField).first).controller?.text,
+ 'Original',
+ );
+ expect(
+ tester
+ .widget(find.byType(TextField).first)
+ .controller
+ ?.selection
+ .extentOffset,
+ 2,
+ );
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.keyZ);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.keyZ);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
+ await tester.pump();
+
+ expect(markdown, 'Changed\n');
+ expect(
+ tester.widget(find.byType(TextField).first).controller?.text,
+ 'Changed',
+ );
+ });
+
+ testWidgets('WYSIWYG editor replaces document when active file changes', (
+ tester,
+ ) async {
+ final first = parser.parse(
+ filePath: 'first.md',
+ source: 'First original\n',
+ );
+ final second = parser.parse(
+ filePath: 'second.md',
+ source: 'Second original\n',
+ );
+ var activeDocument = first.busyDocument;
+
+ Widget buildEditor() {
+ return MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: activeDocument,
+ onSourceChanged: (_, _) {},
+ ),
+ ),
+ ),
+ );
+ }
+
+ await tester.pumpWidget(buildEditor());
+ await tester.pump();
+
+ await tester.enterText(find.byType(TextField).first, 'Unsaved first tab');
+ await tester.pump();
+
+ expect(
+ tester.widget(find.byType(TextField).first).controller?.text,
+ 'Unsaved first tab',
+ );
+
+ activeDocument = second.busyDocument;
+ await tester.pumpWidget(buildEditor());
+ await tester.pump();
+
+ expect(
+ tester.widget(find.byType(TextField).first).controller?.text,
+ 'Second original',
+ );
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.keyZ);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.keyZ);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
+ await tester.pump();
+
+ expect(
+ tester.widget(find.byType(TextField).first).controller?.text,
+ 'Second original',
+ );
+ });
+
+ testWidgets('WYSIWYG editor applies heading keyboard shortcuts', (
+ tester,
+ ) async {
+ final parsed = parser.parse(filePath: 'topic.md', source: 'Title\n');
+ var markdown = parsed.source;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: parsed.busyDocument,
+ onSourceChanged: (filePath, value) => markdown = value,
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.digit2);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.digit2);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
+ await tester.pump();
+
+ expect(markdown, '## Title\n');
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.digit0);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.digit0);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
+ await tester.pump();
+
+ expect(markdown, 'Title\n');
+ });
+
+ testWidgets(
+ 'WYSIWYG Tab nests eligible list items and Shift+Tab lifts them',
+ (tester) async {
+ final parsed = parser.parse(
+ filePath: 'topic.md',
+ source: '- First\n- Second\n- Third\n',
+ );
+ final emittedMarkdown = [];
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: parsed.busyDocument,
+ onSourceChanged: (_, value) => emittedMarkdown.add(value),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ Finder fieldFinder(String text) => find.byWidgetPredicate(
+ (widget) => widget is TextField && widget.controller?.text == text,
+ description: 'TextField containing "$text"',
+ );
+
+ TextField field(String text) =>
+ tester.widget(fieldFinder(text));
+
+ Future focusAtStart(String text) async {
+ final textField = field(text);
+ textField.focusNode!.requestFocus();
+ textField.controller!.selection = const TextSelection.collapsed(
+ offset: 0,
+ );
+ await tester.pump();
+ }
+
+ final firstStart = tester.getTopLeft(fieldFinder('First')).dx;
+ await focusAtStart('First');
+ await tester.sendKeyEvent(LogicalKeyboardKey.tab);
+ await tester.pump();
+
+ expect(emittedMarkdown, isEmpty);
+ expect(field('First').controller!.text, 'First');
+ expect(tester.getTopLeft(fieldFinder('First')).dx, firstStart);
+ expect(field('First').focusNode!.hasFocus, isTrue);
+
+ final secondStart = tester.getTopLeft(fieldFinder('Second')).dx;
+ await focusAtStart('Second');
+ await tester.sendKeyEvent(LogicalKeyboardKey.tab);
+ await tester.pump();
+
+ expect(emittedMarkdown, ['- First\n - Second\n\n- Third\n']);
+ expect(
+ tester.getTopLeft(fieldFinder('Second')).dx,
+ greaterThan(secondStart),
+ );
+ expect(field('Second').focusNode!.hasFocus, isTrue);
+ expect(field('Second').controller!.selection.extentOffset, 0);
+ expect(field('Second').controller!.text, isNot(contains('\t')));
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyEvent(LogicalKeyboardKey.tab);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.pump();
+
+ expect(emittedMarkdown.last, '- First\n\n- Second\n\n- Third\n');
+ expect(emittedMarkdown, hasLength(2));
+ expect(
+ tester.getTopLeft(fieldFinder('Second')).dx,
+ closeTo(secondStart, 0.1),
+ );
+ expect(field('Second').focusNode!.hasFocus, isTrue);
+ expect(field('Second').controller!.selection.extentOffset, 0);
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyEvent(LogicalKeyboardKey.tab);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.pump();
+
+ expect(emittedMarkdown, hasLength(2));
+ expect(field('Second').focusNode!.hasFocus, isTrue);
+ },
+ );
+
+ testWidgets('WYSIWYG arrow keys move focus between paragraphs', (
+ tester,
+ ) async {
+ final parsed = parser.parse(
+ filePath: 'topic.md',
+ source: 'First\n\nSecond\n',
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: parsed.busyDocument,
+ onSourceChanged: (_, _) {},
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ TextField fieldAt(int index) {
+ return tester.widget(find.byType(TextField).at(index));
+ }
+
+ expect(fieldAt(0).focusNode?.hasFocus, isTrue);
+ expect(fieldAt(0).controller?.selection.extentOffset, 0);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
+ await tester.pump();
+
+ expect(fieldAt(1).focusNode?.hasFocus, isTrue);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
+ await tester.pump();
+
+ expect(fieldAt(0).focusNode?.hasFocus, isTrue);
+ });
+
+ testWidgets('WYSIWYG Ctrl+Arrow crosses paragraph boundaries', (
+ tester,
+ ) async {
+ const firstText = 'First paragraph';
+ const secondText = 'Second paragraph';
+ final parsed = parser.parse(
+ filePath: 'topic.md',
+ source: '$firstText\n\n$secondText\n',
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: parsed.busyDocument,
+ onSourceChanged: (_, _) {},
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ TextField fieldAt(int index) =>
+ tester.widget(find.byType(TextField).at(index));
+
+ fieldAt(0).controller!.selection = const TextSelection.collapsed(
+ offset: firstText.length,
+ );
+ await _pressControlShortcut(tester, LogicalKeyboardKey.arrowRight);
+
+ expect(fieldAt(1).focusNode!.hasFocus, isTrue);
+ expect(fieldAt(1).controller!.selection.extentOffset, 0);
+
+ await _pressControlShortcut(tester, LogicalKeyboardKey.arrowLeft);
+
+ expect(fieldAt(0).focusNode!.hasFocus, isTrue);
+ expect(fieldAt(0).controller!.selection.extentOffset, firstText.length);
+ });
+
+ testWidgets('WYSIWYG Ctrl+Shift+Arrow extends into the next paragraph', (
+ tester,
+ ) async {
+ const firstText = 'First paragraph';
+ const secondText = 'Second paragraph';
+ final parsed = parser.parse(
+ filePath: 'topic.md',
+ source: '$firstText\n\n$secondText\n',
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: parsed.busyDocument,
+ onSourceChanged: (_, _) {},
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ TextField fieldAt(int index) =>
+ tester.widget(find.byType(TextField).at(index));
+
+ fieldAt(0).controller!.selection = const TextSelection.collapsed(
+ offset: firstText.length,
+ );
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+ await tester.pump();
+
+ expect(fieldAt(1).focusNode!.hasFocus, isTrue);
+ expect(fieldAt(1).controller!.selection.extentOffset, 0);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
+ await tester.pump();
+
+ expect(fieldAt(1).focusNode!.hasFocus, isTrue);
+ expect(fieldAt(1).controller!.selection.extentOffset, greaterThan(0));
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
+ await tester.pump();
+
+ expect(fieldAt(0).focusNode!.hasFocus, isTrue);
+ expect(
+ fieldAt(0).controller!.selection,
+ const TextSelection.collapsed(offset: firstText.length),
+ );
+ });
+
+ testWidgets('WYSIWYG repeated arrow keys keep moving between paragraphs', (
+ tester,
+ ) async {
+ final parsed = parser.parse(
+ filePath: 'topic.md',
+ source: 'First\n\nSecond\n\nThird\n',
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: Scaffold(
+ body: SizedBox(
+ width: 900,
+ height: 640,
+ child: BusyMarkWysiwygEditor(
+ document: parsed.busyDocument,
+ onSourceChanged: (_, _) {},
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ TextField fieldAt(int index) {
+ return tester.widget(find.byType(TextField).at(index));
+ }
+
+ expect(fieldAt(0).focusNode?.hasFocus, isTrue);
+
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
+ await tester.pump();
+ expect(fieldAt(1).focusNode?.hasFocus, isTrue);
+
+ await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowDown);
+ await tester.pump();
+ expect(fieldAt(2).focusNode?.hasFocus, isTrue);
+
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
+ });
+
+ testWidgets(
+ 'WYSIWYG Shift+Down selection continues character by character in the next paragraph',
+ (tester) async {
+ const first = 'As a learner,';
+ const second = 'I want to submit audio-transcription probes,';
+ final parsed = parser.parse(
+ filePath: 'topic.md',
+ source: '$first\n\n$second\n\nSo I can recognize words.\n',
+ );
+ String? copiedText;
+ tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
+ SystemChannels.platform,
+ (call) async {
+ if (call.method == 'Clipboard.setData') {
+ final arguments = call.arguments as Map