From 993a9a9621937b66b46e2ad992150c5997643222 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 21 Oct 2026 14:29:52 +0200 Subject: [PATCH] feat(write): editable quotes with lists inside, uploaded cover images, Write-only welcome content (T-19) - Sign-up creates only the Write welcome document; no welcome spreadsheet. - Quote text can be clicked, selected and edited again: the quote bar's painter no longer absorbs taps and clicks. The bars of a multi-paragraph quote join into one unbroken bar. - Lists inside quotes: a list line may carry `blockquote: true` (bullet, numbered, task, any indent). Supported in the validator, normalizer, IR, Markdown and HTML import/export, TXT/DOCX/ODT/RTF/PDF export, the editor (rendering, Markdown shortcuts, Enter/Backspace/Tab, block-type commands) and MCP `write.set_block_type` (`quoted`). - Submit dialog: the cover image is uploaded in the dialog as an image file of the story's document instead of picked from its text; the server accepts it, lists it in the webhook's `assets` and refuses an invalid required cover. - HTML import keeps a whitespace-only heading (found by the property tests). Co-Authored-By: Claude Opus 5.5 --- docs/blog-integration.md | 4 +- .../wonky_app/lib/src/shell/form_dialog.dart | 204 +++++++ .../shell/form_dialog_image_field_test.dart | 88 +++ packages/wonky_core/lib/mcp/write_tools.dart | 4 +- .../wonky_rich_text/lib/src/attributes.dart | 43 +- .../lib/src/delta/normalizer.dart | 25 +- .../lib/src/delta/validator.dart | 14 +- .../lib/src/edit/document_edits.dart | 20 +- .../lib/src/edit/document_reader.dart | 1 + .../lib/src/export/docx/docx_exporter.dart | 24 + .../lib/src/export/html/html_writer.dart | 35 +- .../lib/src/export/odt/odt_exporter.dart | 11 +- .../lib/src/export/pdf/pdf_exporter.dart | 37 +- .../lib/src/export/rtf/rtf_exporter.dart | 14 +- .../lib/src/export/txt_exporter.dart | 23 +- .../lib/src/import/html/html_importer.dart | 38 +- .../wonky_write_core/lib/src/ir/doc_tree.dart | 17 + .../lib/src/ir/doc_tree_builder.dart | 24 +- .../lib/src/markdown/markdown_exporter.dart | 21 +- .../lib/src/markdown/markdown_importer.dart | 34 +- .../lib/src/testing/generators.dart | 22 +- .../test/export/quoted_list_export_test.dart | 74 +++ .../test/markdown/markdown_codec_test.dart | 17 - .../test/markdown/quoted_list_test.dart | 162 +++++ specs/04-auth-and-accounts.md | 6 +- specs/18-publications-and-submissions.md | 31 +- specs/19-usage-analytics.md | 6 +- specs/wonky_write/02-document-model.md | 17 +- specs/wonky_write/04-editor.md | 32 +- specs/wonky_write/07-import-export-print.md | 6 +- specs/wonky_write/09-mcp-and-ai.md | 6 +- .../drive/templates/builtin_templates.dart | 3 + .../publications/submission_service.dart | 35 +- .../src/platform/users/account_service.dart | 13 +- .../publications/submission_cover_test.dart | 147 +++++ .../platform/user_endpoint_test.dart | 17 +- .../write/mcp/write_tools_test.dart | 25 + .../commands/editor_command_catalog.dart | 5 +- .../commands/write_editor_commands.dart | 62 +- .../editor/components/wonky_components.dart | 118 +++- .../editor/keyboard/editor_keyboard.dart | 36 +- .../editor/mapping/delta_node_codec.dart | 15 +- .../features/editor/mapping/wonky_schema.dart | 15 + .../reactions/block_conversion_reactions.dart | 60 +- .../requests/block_editing_handlers.dart | 90 ++- .../schema_guard/schema_guard_reaction.dart | 8 +- .../editor/styles/write_stylesheet.dart | 32 +- .../lib/features/editor/ui/wonky_editor.dart | 8 + .../lib/features/publish/submit_dialog.dart | 91 +-- .../lib/l10n/gen/write_localizations.dart | 40 +- .../lib/l10n/gen/write_localizations_en.dart | 47 +- wonky_write/lib/l10n/write_en.arb | 16 +- wonky_write/lib/l10n/write_en_XA.arb | 10 +- .../document/support/session_harness.dart | 39 +- .../editor/components/blockquote_test.dart | 564 ++++++++++++++++++ .../features/publish/submission_test.dart | 124 ++++ .../screen/support/screen_harness.dart | 10 +- 57 files changed, 2399 insertions(+), 291 deletions(-) create mode 100644 packages/wonky_app/test/shell/form_dialog_image_field_test.dart create mode 100644 packages/wonky_write_core/test/export/quoted_list_export_test.dart create mode 100644 packages/wonky_write_core/test/markdown/quoted_list_test.dart create mode 100644 wonky_server/test/integration/platform/publications/submission_cover_test.dart create mode 100644 wonky_write/test/features/editor/components/blockquote_test.dart diff --git a/docs/blog-integration.md b/docs/blog-integration.md index 312983f8..bc6bacfa 100644 --- a/docs/blog-integration.md +++ b/docs/blog-integration.md @@ -280,7 +280,9 @@ Ignore types you don't know; new ones may be added. - `revision` is the document revision that was sent. When you publish, publish the content of the submit you reviewed; the contributor may keep editing in the meantime. - `fields` holds the fields you turned on; fields you didn't turn on are `null` (`tags` is then - `[]`). `coverImage` is the id of one of the `assets`. + `[]`). `coverImage` is the id of one of the `assets`. The contributor uploads the cover in the + Submit dialog, so it is usually **not** in the `markdown`: show it as the post's cover without + removing anything from the text. - `markdown` is CommonMark with GitHub tables and task lists. Open suggestions in the document are sent as the original text; comments are never included. There is no front matter: the title and the other fields are in `fields` (the Markdown usually starts with the title as a heading, diff --git a/packages/wonky_app/lib/src/shell/form_dialog.dart b/packages/wonky_app/lib/src/shell/form_dialog.dart index 7a332f9b..0cc09584 100644 --- a/packages/wonky_app/lib/src/shell/form_dialog.dart +++ b/packages/wonky_app/lib/src/shell/form_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:shad/shad.dart'; @@ -184,6 +186,46 @@ class SelectFieldSpec extends DialogFieldSpec { final String initial; } +/// What [ImageFieldSpec.pick] returns: the new value (an id) and its +/// thumbnail. +typedef ImageFieldPick = ({String value, ImageProvider? preview}); + +/// An image the user uploads in the dialog (e.g. a story's cover image, +/// specs/18 §4.2): a thumbnail with *Upload* (*Replace* once set) and +/// *Remove*. The value is an id, `''` for none. [pick] picks and uploads an +/// image and returns its id (null when cancelled; a throw shows +/// [errorLabel]); [preview] resolves the thumbnail of [initial]. Keys: +/// `.` (the field), `.preview`, `.upload`, `.remove`. +class ImageFieldSpec extends DialogFieldSpec { + const ImageFieldSpec({ + required super.id, + required super.label, + required this.pick, + required this.uploadLabel, + required this.replaceLabel, + required this.removeLabel, + required this.emptyLabel, + required this.uploadingLabel, + required this.errorLabel, + this.initial = '', + this.preview, + this.description, + }); + + final String initial; + final Future Function() pick; + final FutureOr Function(String value)? preview; + final String uploadLabel; + final String replaceLabel; + final String removeLabel; + + /// Shown in the thumbnail box without an image. + final String emptyLabel; + final String uploadingLabel; + final String errorLabel; + final String? description; +} + /// A dialog button. class DialogButtonSpec { const DialogButtonSpec({ @@ -300,10 +342,54 @@ class _WonkyFormDialogState extends State }; case SelectFieldSpec(): _values[f.id] = f.initial; + case ImageFieldSpec(): + _values[f.id] = f.initial; + _resolvePreview(f, f.initial); } } } + /// Thumbnails of [ImageFieldSpec]s; ids of those uploading. + final Map _previews = {}; + final Set _uploading = {}; + + void _resolvePreview(ImageFieldSpec f, String value) { + _previews[f.id] = null; + if (value.isEmpty || f.preview == null) return; + final p = f.preview!(value); + if (p is Future) { + unawaited( + p.then((provider) { + if (mounted && _values[f.id] == value) { + setState(() => _previews[f.id] = provider); + } + }, onError: (Object _) {}), + ); + } else { + _previews[f.id] = p; + } + } + + Future _pickImage(ImageFieldSpec f) async { + setState(() { + _uploading.add(f.id); + _errors[f.id] = null; + }); + try { + final picked = await f.pick(); + if (!mounted || picked == null) return; + setState(() { + _values[f.id] = picked.value; + _previews[f.id] = picked.preview; + }); + if (picked.preview == null) _resolvePreview(f, picked.value); + } catch (e) { + if (mounted) setState(() => _errors[f.id] = f.errorLabel); + } finally { + if (mounted) setState(() => _uploading.remove(f.id)); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -352,6 +438,8 @@ class _WonkyFormDialogState extends State } void _press(DialogButtonSpec b) { + // Not before an image field's upload has finished. + if (b.id != widget.spec.cancelId && _uploading.isNotEmpty) return; if (b.id != widget.spec.cancelId && b.validates && !_validate()) return; Navigator.of(context).pop(DialogResult(b.id, _all)); } @@ -377,7 +465,10 @@ class _WonkyFormDialogState extends State CheckboxFieldSpec() => 'checkbox', ChecklistFieldSpec() => 'checklist', SelectFieldSpec() => 'select', + ImageFieldSpec() => 'image', }, + if (f is ImageFieldSpec && _uploading.contains(f.id)) + 'uploading': true, 'value': _all[f.id], if (f is ChecklistFieldSpec) ...{ 'options': [for (final i in f.items) i.value], @@ -416,6 +507,13 @@ class _WonkyFormDialogState extends State case SelectFieldSpec(): if (!f.options.containsKey(value)) return false; setState(() => _values[id] = value); + case ImageFieldSpec(): + // An id of an image that is already uploaded, or '' for none. + if (value is! String) return false; + setState(() { + _values[id] = value; + _resolvePreview(f, value); + }); case null: return false; } @@ -498,6 +596,110 @@ class _WonkyFormDialogState extends State ); } + Widget _image(ImageFieldSpec f, ShadThemeData theme, String? key) { + final cs = theme.colorScheme; + final value = _values[f.id]! as String; + final preview = _previews[f.id]; + final uploading = _uploading.contains(f.id); + ValueKey? k(String part) => + key == null ? null : ValueKey('$key.${f.id}$part'); + final Widget thumbnail; + if (uploading) { + thumbnail = Text(f.uploadingLabel, style: theme.textTheme.muted); + } else if (value.isEmpty) { + thumbnail = Column( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon(LucideIcons.image, size: 20, color: cs.mutedForeground), + Text( + f.emptyLabel, + textAlign: TextAlign.center, + style: theme.textTheme.muted, + ), + ], + ); + } else if (preview == null) { + thumbnail = Icon(LucideIcons.image, size: 20, color: cs.mutedForeground); + } else { + thumbnail = Image( + key: k('.preview'), + image: preview, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, _, _) => + Icon(LucideIcons.imageOff, size: 20, color: cs.mutedForeground), + ); + } + return Column( + key: k(''), + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(f.label, style: theme.textTheme.small), + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 12, + children: [ + // 16:9, the usual shape of a blog's cover. + Container( + width: 128, + height: 72, + alignment: Alignment.center, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: cs.muted, + border: Border.all(color: cs.border), + borderRadius: theme.radius, + ), + child: thumbnail, + ), + Flexible( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ShadButton.outline( + key: k('.upload'), + size: ShadButtonSize.sm, + enabled: !uploading, + leading: const Icon(LucideIcons.upload, size: 16), + onPressed: () => unawaited(_pickImage(f)), + child: Text(value.isEmpty ? f.uploadLabel : f.replaceLabel), + ), + if (value.isNotEmpty && !uploading) + ShadButton.ghost( + key: k('.remove'), + size: ShadButtonSize.sm, + leading: const Icon(LucideIcons.x, size: 16), + onPressed: () => setState(() { + _values[f.id] = ''; + _previews[f.id] = null; + }), + child: Text(f.removeLabel), + ), + ], + ), + ), + ], + ), + if (f.description != null) ...[ + const SizedBox(height: 6), + Text(f.description!, style: theme.textTheme.muted), + ], + if (_errors[f.id] != null) ...[ + const SizedBox(height: 4), + Text( + _errors[f.id]!, + style: theme.textTheme.small.copyWith(color: cs.destructive), + ), + ], + ], + ); + } + @override Widget build(BuildContext context) { final spec = widget.spec; @@ -587,6 +789,8 @@ class _WonkyFormDialogState extends State ], ), ); + case ImageFieldSpec(): + fields.add(_image(f, theme, key)); } } // Below shad's `sm` breakpoint the actions stack vertically and each diff --git a/packages/wonky_app/test/shell/form_dialog_image_field_test.dart b/packages/wonky_app/test/shell/form_dialog_image_field_test.dart new file mode 100644 index 00000000..ab99057f --- /dev/null +++ b/packages/wonky_app/test/shell/form_dialog_image_field_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shad/shad.dart'; +import 'package:wonky_app/wonky_app.dart'; + +Finder _key(String k) => find.byKey(ValueKey(k)); + +/// The image field of the form dialog (the submit dialog's cover image, +/// specs/18 §4.2). +void main() { + late Future result; + + Future open( + WidgetTester tester, + Future Function() pick, + ) async { + await tester.pumpWidget( + ShadApp( + home: Builder( + builder: (context) => Center( + child: ShadButton( + onPressed: () => result = showWonkyFormDialog( + context, + WonkyDialogSpec( + title: 'Submit', + dialogKey: 'dialog.test', + fields: [ + ImageFieldSpec( + id: 'cover', + label: 'Cover image', + pick: pick, + uploadLabel: 'Upload image', + replaceLabel: 'Replace', + removeLabel: 'Remove', + emptyLabel: 'No cover', + uploadingLabel: 'Uploading…', + errorLabel: 'Upload failed', + ), + ], + buttons: const [ + DialogButtonSpec(id: 'cancel', label: 'Cancel'), + DialogButtonSpec(id: 'ok', label: 'OK', primary: true), + ], + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + return tester.state(find.byType(WonkyFormDialog)) + as McpDescribableDialog; + } + + testWidgets('Given an image field then MCP sees it as an image and can set ' + 'and clear its value', (tester) async { + final dialog = await open(tester, () async => null); + + final field = (dialog.describe()['fields']! as List).single as Map; + expect(field['type'], 'image'); + expect(field['value'], ''); + expect(dialog.setField('cover', 'file-1'), isTrue); + await tester.pump(); + expect(find.text('Replace'), findsOneWidget); + expect(dialog.setField('cover', 3), isFalse); + + dialog.press('ok'); + await tester.pumpAndSettle(); + expect((await result)!.text('cover'), 'file-1'); + }); + + testWidgets('Given an upload that fails then the error shows and the value ' + 'stays empty', (tester) async { + final dialog = await open(tester, () async => throw StateError('offline')); + + await tester.tap(_key('dialog.test.cover.upload')); + await tester.pumpAndSettle(); + + expect(find.text('Upload failed'), findsOneWidget); + expect(find.text('Upload image'), findsOneWidget); + dialog.press('ok'); + await tester.pumpAndSettle(); + expect((await result)!.text('cover'), ''); + }); +} diff --git a/packages/wonky_core/lib/mcp/write_tools.dart b/packages/wonky_core/lib/mcp/write_tools.dart index 8319ea23..e88c124e 100644 --- a/packages/wonky_core/lib/mcp/write_tools.dart +++ b/packages/wonky_core/lib/mcp/write_tools.dart @@ -426,7 +426,8 @@ abstract final class WriteTools { title: 'Set the block type', description: 'Changes every line touched by the range to a paragraph, heading ' - '(level 1–6), bullet/ordered/task list item (indent 0–8, checked), ' + '(level 1–6), bullet/ordered/task list item (indent 0–8, checked; ' + 'a list item in a quote keeps the quote unless quoted is false), ' 'quote or code block (language). Converting lines to code merges ' 'them into one code block.$_addressing', inputSchema: WriteSchemas.doc( @@ -446,6 +447,7 @@ abstract final class WriteTools { 'level': const JsonSchema.integer(minimum: 1, maximum: 6), 'indent': const JsonSchema.integer(minimum: 0, maximum: 8), 'checked': const JsonSchema.boolean(), + 'quoted': const JsonSchema.boolean(), 'language': const JsonSchema.string(maxLength: 40), }, required: ['range', 'type'], diff --git a/packages/wonky_rich_text/lib/src/attributes.dart b/packages/wonky_rich_text/lib/src/attributes.dart index 9ca3a916..753e7008 100644 --- a/packages/wonky_rich_text/lib/src/attributes.dart +++ b/packages/wonky_rich_text/lib/src/attributes.dart @@ -64,7 +64,8 @@ abstract final class WonkyAttr { /// All block attribute keys. static const blockKeys = {header, blockquote, codeBlock, list, indent}; - /// Mutually exclusive block-type keys (in normalization priority order). + /// Mutually exclusive block-type keys (in normalization priority order); + /// only [blockquote] may sit next to [list] (a list item in a quote). static const blockTypeKeys = [codeBlock, header, list, blockquote]; /// Maximum list indent. @@ -163,6 +164,7 @@ final class BlockFormat { this.indent = 0, this.checked = false, this.language = '', + this.quoted = false, }); /// A plain paragraph. @@ -186,6 +188,15 @@ final class BlockFormat { /// Code block language (`""` = none). final String language; + /// Whether a list or task item is inside a blockquote (`blockquote: true` + /// next to `list`, Write 02 §1.2). Always false for other types: a quote + /// line is [BlockType.quote]. + final bool quoted; + + /// Whether the line is part of a blockquote (a quote line or a quoted + /// list item). + bool get inQuote => type == BlockType.quote || quoted; + /// Whether this is a list (bullet, ordered or task) item. bool get isList => type == BlockType.bullet || @@ -216,15 +227,29 @@ final class BlockFormat { final indent = rawIndent is int ? rawIndent.clamp(0, WonkyAttr.maxIndent) : 0; + final quoted = attributes[WonkyAttr.blockquote] == true; return switch (list) { - ListKind.bullet => BlockFormat(BlockType.bullet, indent: indent), - ListKind.ordered => BlockFormat(BlockType.ordered, indent: indent), + ListKind.bullet => BlockFormat( + BlockType.bullet, + indent: indent, + quoted: quoted, + ), + ListKind.ordered => BlockFormat( + BlockType.ordered, + indent: indent, + quoted: quoted, + ), ListKind.checked => BlockFormat( BlockType.task, indent: indent, checked: true, + quoted: quoted, + ), + ListKind.unchecked => BlockFormat( + BlockType.task, + indent: indent, + quoted: quoted, ), - ListKind.unchecked => BlockFormat(BlockType.task, indent: indent), }; } if (attributes[WonkyAttr.blockquote] == true) { @@ -242,6 +267,7 @@ final class BlockFormat { BlockType.bullet || BlockType.ordered || BlockType.task => { WonkyAttr.list: listKind!.value, if (indent > 0) WonkyAttr.indent: indent, + if (quoted) WonkyAttr.blockquote: true, }, }; @@ -269,15 +295,18 @@ final class BlockFormat { other.level == level && other.indent == indent && other.checked == checked && - other.language == language; + other.language == language && + other.quoted == quoted; @override - int get hashCode => Object.hash(type, level, indent, checked, language); + int get hashCode => + Object.hash(type, level, indent, checked, language, quoted); @override String toString() => 'BlockFormat($type, level: $level, indent: $indent, ' - 'checked: $checked, language: "$language")'; + 'checked: $checked, language: "$language"' + '${quoted ? ', quoted' : ''})'; } /// Kind of a suggestion part. diff --git a/packages/wonky_write_core/lib/src/delta/normalizer.dart b/packages/wonky_write_core/lib/src/delta/normalizer.dart index 8d521037..61002ed3 100644 --- a/packages/wonky_write_core/lib/src/delta/normalizer.dart +++ b/packages/wonky_write_core/lib/src/delta/normalizer.dart @@ -264,6 +264,7 @@ final class WonkyDeltaNormalizer { startsDocument: start == 0, endsDocument: end == docLength, prevListIndent: listIndent(before), + prevListQuoted: BlockFormat.fromAttributes(before).quoted, ); // The change within the window. @@ -342,6 +343,7 @@ final class WonkyDeltaNormalizer { } int? prevListIndent = window.prevListIndent; + var prevListQuoted = window.prevListQuoted; for (final line in doc.lines) { final isLast = window.endsDocument && line.index == doc.lines.length - 1; final blockIn = line.attributes; @@ -388,10 +390,11 @@ final class WonkyDeltaNormalizer { // Indent clamping uses the output state at each emitted line end. Map clamped() => - _clampIndent(blockOut, prevListIndent, fixes); + _clampIndent(blockOut, prevListIndent, prevListQuoted, fixes); void track(Map attrs) { final f = BlockFormat.fromAttributes(attrs); prevListIndent = f.isList ? f.indent : null; + prevListQuoted = f.quoted; } for (var gi = 0; gi < groups.length; gi++) { @@ -687,6 +690,11 @@ final class WonkyDeltaNormalizer { fixes.add(NormalizationFix.removedAttributes); continue; } + if (type == WonkyAttr.list && key == WonkyAttr.blockquote) { + // A list item inside a quote (Write 02 §1.2). + out[key] = value; + continue; + } if (type != null) { fixes.add(NormalizationFix.resolvedBlockConflict); continue; @@ -721,14 +729,20 @@ final class WonkyDeltaNormalizer { return out; } + /// Clamps a list line's indent to one deeper than the previous list line + /// of the same run; a quoted and an unquoted list are two runs. Map _clampIndent( Map block, int? prevListIndent, + bool prevListQuoted, Set fixes, ) { if (!block.containsKey(WonkyAttr.list)) return block; final indent = (block[WonkyAttr.indent] as int?) ?? 0; - final maxAllowed = prevListIndent == null ? 0 : prevListIndent + 1; + final quoted = block[WonkyAttr.blockquote] == true; + final maxAllowed = prevListIndent == null || prevListQuoted != quoted + ? 0 + : prevListIndent + 1; if (indent <= maxAllowed) return block; fixes.add(NormalizationFix.clampedIndent); final out = Map.from(block); @@ -954,12 +968,14 @@ final class _Window { required this.startsDocument, required this.endsDocument, required this.prevListIndent, + required this.prevListQuoted, }); const _Window.whole() : startsDocument = true, endsDocument = true, - prevListIndent = null; + prevListIndent = null, + prevListQuoted = false; /// Whether the first line is the document's first. final bool startsDocument; @@ -969,4 +985,7 @@ final class _Window { /// List indent of the line before the first (null: not a list line). final int? prevListIndent; + + /// Whether the line before the first is a quoted list item. + final bool prevListQuoted; } diff --git a/packages/wonky_write_core/lib/src/delta/validator.dart b/packages/wonky_write_core/lib/src/delta/validator.dart index af2ebf0c..159114d1 100644 --- a/packages/wonky_write_core/lib/src/delta/validator.dart +++ b/packages/wonky_write_core/lib/src/delta/validator.dart @@ -31,7 +31,8 @@ enum ValidationCode { /// An attribute value of the wrong type or range. invalidAttributeValue, - /// More than one of `header`, `blockquote`, `code-block`, `list`. + /// More than one of `header`, `blockquote`, `code-block`, `list` (only + /// `blockquote` with `list` is allowed: a list item inside a quote). conflictingBlockAttributes, /// `indent` without `list`, or an indent deeper than allowed by the @@ -169,7 +170,9 @@ final class WonkyDeltaValidator { final pendingInline = []; final pendingSoftBreaks = []; // List run state. + // (A quoted and an unquoted list next to each other are two runs.) int? prevListIndent; + var prevListQuoted = false; Operation? prev; var prevWasEmbed = false; @@ -281,7 +284,10 @@ final class WonkyDeltaValidator { pendingInline.clear(); // Indent rules. if (format != null && format.isList) { - final maxAllowed = prevListIndent == null ? 0 : prevListIndent + 1; + final maxAllowed = + prevListIndent == null || prevListQuoted != format.quoted + ? 0 + : prevListIndent + 1; if (format.indent > maxAllowed) { add( ValidationCode.invalidIndent, @@ -290,6 +296,7 @@ final class WonkyDeltaValidator { ); } prevListIndent = format.indent; + prevListQuoted = format.quoted; } else { prevListIndent = null; } @@ -465,7 +472,8 @@ final class WonkyDeltaValidator { ); } case WonkyAttr.blockquote: - types++; + // A list item may be inside a quote (Write 02 §1.2). + if (!attrs.containsKey(WonkyAttr.list)) types++; if (v != true) { add( ValidationCode.invalidAttributeValue, diff --git a/packages/wonky_write_core/lib/src/edit/document_edits.dart b/packages/wonky_write_core/lib/src/edit/document_edits.dart index 9248a6a7..a8c37148 100644 --- a/packages/wonky_write_core/lib/src/edit/document_edits.dart +++ b/packages/wonky_write_core/lib/src/edit/document_edits.dart @@ -104,11 +104,16 @@ final class BlockTypeSpec { this.indent, this.checked, this.language, + this.quoted, }); /// Block type. final BlockType type; + /// Whether list and task items are inside a quote (`null` keeps whether + /// the line is in a quote: a quote line becomes a quoted list item). + final bool? quoted; + /// Heading level 1–6. final int? level; @@ -166,6 +171,7 @@ final class BlockTypeSpec { indent: indent, checked: json['checked'] as bool?, language: json['language'] as String?, + quoted: json['quoted'] as bool?, ); } } @@ -703,6 +709,7 @@ final class DocumentEdits { if (line.isEmbedLine || !line.hasNewline) continue; final current = line.format; final indent = spec.indent ?? (current.isList ? current.indent : 0); + final quoted = spec.quoted ?? current.inQuote; final target = switch (spec.type) { BlockType.paragraph => BlockFormat.paragraph, BlockType.heading => BlockFormat.heading( @@ -715,14 +722,23 @@ final class DocumentEdits { spec.language ?? (current.type == BlockType.code ? current.language : ''), ), - BlockType.bullet => BlockFormat(BlockType.bullet, indent: indent), - BlockType.ordered => BlockFormat(BlockType.ordered, indent: indent), + BlockType.bullet => BlockFormat( + BlockType.bullet, + indent: indent, + quoted: quoted, + ), + BlockType.ordered => BlockFormat( + BlockType.ordered, + indent: indent, + quoted: quoted, + ), BlockType.task => BlockFormat( BlockType.task, indent: indent, checked: spec.checked ?? (current.type == BlockType.task && current.checked), + quoted: quoted, ), }; if (spec.type == BlockType.code) { diff --git a/packages/wonky_write_core/lib/src/edit/document_reader.dart b/packages/wonky_write_core/lib/src/edit/document_reader.dart index c18690f9..292cf9b9 100644 --- a/packages/wonky_write_core/lib/src/edit/document_reader.dart +++ b/packages/wonky_write_core/lib/src/edit/document_reader.dart @@ -268,6 +268,7 @@ final class DocumentReader { if (b.type == 'heading') 'level': f.level, if (f.isList) 'indent': f.indent, if (b.type == 'task') 'checked': f.checked, + if (f.quoted) 'quoted': true, if (b.type == 'code') 'language': f.language, 'delta_start': b.start, 'delta_length': b.length, diff --git a/packages/wonky_write_core/lib/src/export/docx/docx_exporter.dart b/packages/wonky_write_core/lib/src/export/docx/docx_exporter.dart index 82d5987c..fb6ef225 100644 --- a/packages/wonky_write_core/lib/src/export/docx/docx_exporter.dart +++ b/packages/wonky_write_core/lib/src/export/docx/docx_exporter.dart @@ -509,6 +509,28 @@ final class _DocxJob { return out; } + /// A list item inside a quote (Write 02 §1.2): the level's numbering indent + /// moved right by the quote's indent, with the quote's bar and background. + void _quoted(ParaProps p, int indent) { + final q = theme.blockquote; + final l = theme.list; + final level = indent.clamp(0, 8); + p + ..indentLeft = + q.box.padding.left + + (q.bar.isVisible ? q.bar.width : 0) + + l.indentFor(level) + ..hanging = l.indentPerLevel; + if (q.bar.isVisible) { + p.left = docxBorder( + 'left', + q.bar, + space: q.box.padding.left.clamp(0, 31), + ); + } + if (q.box.background != null) p.shading = q.box.background; + } + String _body() { final b = StringBuffer(); final blocks = tree.blocks; @@ -594,6 +616,7 @@ final class _DocxJob { ..numbering = (numId, indent.clamp(0, 8)); if (before != 0) p.spaceBefore = before; if (after != theme.list.itemSpacing) p.spaceAfter = after; + if (block.inQuote) _quoted(p, indent); b.write(_paragraph(p, runs)); case TaskBlock(:final checked, :final indent, :final runs): orderedNum.remove(indent); @@ -602,6 +625,7 @@ final class _DocxJob { ..numbering = (DocxNumIds.tasks, indent.clamp(0, 8)); if (before != 0) p.spaceBefore = before; if (after != theme.list.itemSpacing) p.spaceAfter = after; + if (block.inQuote) _quoted(p, indent); final glyph = RunProps() ..font = 'Segoe UI Symbol' ..color = theme.task.checkbox.borderColor; diff --git a/packages/wonky_write_core/lib/src/export/html/html_writer.dart b/packages/wonky_write_core/lib/src/export/html/html_writer.dart index 0a20306c..06226a16 100644 --- a/packages/wonky_write_core/lib/src/export/html/html_writer.dart +++ b/packages/wonky_write_core/lib/src/export/html/html_writer.dart @@ -106,8 +106,11 @@ final class HtmlBodyWriter { return b.toString(); } - /// HTML for a sequence of blocks (lists are nested from indents). - String blocks(List blocks) { + /// HTML for a sequence of blocks (lists are nested from indents; quote + /// lines and the quoted list items next to them share one `
`). + String blocks(List blocks) => _blocks(blocks, quoteBody: false); + + String _blocks(List blocks, {required bool quoteBody}) { final b = StringBuffer(); // Open list stack: (tag, kind key). final stack = <(String tag, String key)>[]; @@ -117,7 +120,26 @@ final class HtmlBodyWriter { } } - for (final block in blocks) { + for (var i = 0; i < blocks.length; i++) { + final block = blocks[i]; + if (!quoteBody && isInQuote(block)) { + closeTo(0); + var end = i; + while (end < blocks.length && isInQuote(blocks[end])) { + end++; + } + b + ..write('
') + ..write(_blocks(blocks.sublist(i, end), quoteBody: true)) + ..write('
\n'); + i = end - 1; + continue; + } + if (quoteBody && block is QuoteBlock) { + closeTo(0); + b.write(_quoteLines(block.lines)); + continue; + } if (block is ListItemBlock || block is TaskBlock) { final (indent, tag, key, item) = switch (block) { ListItemBlock(:final indent, :final kind, :final runs) => ( @@ -166,6 +188,11 @@ final class HtmlBodyWriter { return b.toString(); } + /// The `

`s of quote lines (inside a `

`). + String _quoteLines(List> lines) => lines + .map((l) => l.isEmpty ? '

$_br

' : '

${inline(l)}

') + .join(); + /// HTML of one non-list block. String block(DocBlock block) { switch (block) { @@ -174,7 +201,7 @@ final class HtmlBodyWriter { case HeadingBlock(:final level, :final runs, :final anchorId): return '${runs.isEmpty ? _br : inline(runs)}'; case QuoteBlock(:final lines): - return '
${lines.map((l) => l.isEmpty ? '

$_br

' : '

${inline(l)}

').join()}
'; + return '
${_quoteLines(lines)}
'; case CodeBlock(:final language, :final text): final cls = language.isEmpty ? '' diff --git a/packages/wonky_write_core/lib/src/export/odt/odt_exporter.dart b/packages/wonky_write_core/lib/src/export/odt/odt_exporter.dart index 11f332b9..8ed99a91 100644 --- a/packages/wonky_write_core/lib/src/export/odt/odt_exporter.dart +++ b/packages/wonky_write_core/lib/src/export/odt/odt_exporter.dart @@ -317,9 +317,11 @@ final class _OdtWriter { } final b = blocks[i]; if (b is ListItemBlock || b is TaskBlock) { + // A list inside a quote is its own run (Write 02 §1.2). var j = i; while (j < blocks.length && - (blocks[j] is ListItemBlock || blocks[j] is TaskBlock)) { + (blocks[j] is ListItemBlock || blocks[j] is TaskBlock) && + isInQuote(blocks[j]) == isInQuote(b)) { j++; } _listRun(blocks.sublist(i, j)); @@ -749,8 +751,13 @@ final class _OdtWriter { closeItem(list); _body.write(''); list.itemOpen = true; + // Quoted items take the quote's bar, padding and background. final style = _p( - checked == true ? _taskChecked : 'List_20_Paragraph', + checked == true + ? _taskChecked + : isInQuote(item) + ? 'Quotations' + : 'List_20_Paragraph', _groupMargins( n, items.length, diff --git a/packages/wonky_write_core/lib/src/export/pdf/pdf_exporter.dart b/packages/wonky_write_core/lib/src/export/pdf/pdf_exporter.dart index 381d5790..2246be0f 100644 --- a/packages/wonky_write_core/lib/src/export/pdf/pdf_exporter.dart +++ b/packages/wonky_write_core/lib/src/export/pdf/pdf_exporter.dart @@ -439,6 +439,7 @@ final class _PdfJob { if (i % 8 == 7) await _turn(); final b = source[i]; final afterHeading = prev is HeadingBlock; + final next = i + 1 < source.length ? source[i + 1] : null; switch (b) { case ParagraphBlock(:final runs): final box = t.body.box; @@ -518,6 +519,7 @@ final class _PdfJob { ordered: kind == ListKindIr.ordered, ordinal: ordinal, prev: prev, + next: next, ), ); case TaskBlock(:final checked, :final indent, :final runs): @@ -530,6 +532,7 @@ final class _PdfJob { task: true, checked: checked, prev: prev, + next: next, ), ); case QuoteBlock(:final lines): @@ -556,11 +559,15 @@ final class _PdfJob { kind: _Kind.text, lines: laid, textLeft: left, - paddingTop: q.box.padding.top, + // Next to a quoted list item, the gap is inside the quote + // (one unbroken bar, Write 02 §1.2). + paddingTop: + q.box.padding.top + + (isInQuote(prev) ? t.list.box.spaceAfter : 0), paddingBottom: q.box.padding.bottom, background: q.box.background, - spaceBefore: q.box.spaceBefore, - spaceAfter: q.box.spaceAfter, + spaceBefore: isInQuote(prev) ? 0 : q.box.spaceBefore, + spaceAfter: isInQuote(next) ? 0 : q.box.spaceAfter, )..leftBar = q.bar.isVisible ? q.bar : null, ); case CodeBlock(:final language, :final text): @@ -601,12 +608,20 @@ final class _PdfJob { bool task = false, bool checked = false, DocBlock? prev, + DocBlock? next, }) { final t = theme; final l = t.list; final style = task ? t.task.style : l.style; - final left = l.indentPerLevel * (indent + 1); - final width = contentWidth - left; + final q = t.blockquote; + final quoted = isInQuote(b); + // A list inside a quote (Write 02 §1.2): moved right by the quote's + // indent, under its bar and background. + final quoteLeft = quoted + ? q.box.padding.left + (q.bar.isVisible ? q.bar.width : 0.0) + : 0.0; + final left = quoteLeft + l.indentPerLevel * (indent + 1); + final width = contentWidth - left - (quoted ? q.box.padding.right : 0); final lines = breaker.layout( _runs( runs, @@ -620,15 +635,23 @@ final class _PdfJob { ); final firstInRun = prev is! ListItemBlock && prev is! TaskBlock; final box = task ? t.task.box : l.box; + final before = firstInRun ? box.spaceBefore : l.itemSpacing; + // Inside a quote the gaps to the quote's other parts are padding, so + // the bars touch. + final joinPrev = quoted && isInQuote(prev); + final joinNext = quoted && isInQuote(next); final block = _Block( source: b, index: index, kind: _Kind.text, lines: lines, textLeft: left, - spaceBefore: firstInRun ? box.spaceBefore : l.itemSpacing, - spaceAfter: l.itemSpacing, + paddingTop: joinPrev ? before : 0, + background: quoted ? q.box.background : null, + spaceBefore: joinPrev ? 0 : before, + spaceAfter: joinNext ? 0 : l.itemSpacing, ); + if (quoted && q.bar.isVisible) block.leftBar = q.bar; if (task) { block ..task = t.task diff --git a/packages/wonky_write_core/lib/src/export/rtf/rtf_exporter.dart b/packages/wonky_write_core/lib/src/export/rtf/rtf_exporter.dart index d49cdeac..709b2961 100644 --- a/packages/wonky_write_core/lib/src/export/rtf/rtf_exporter.dart +++ b/packages/wonky_write_core/lib/src/export/rtf/rtf_exporter.dart @@ -640,7 +640,17 @@ final class _RtfWriter { _ => const [], }; final style = task ? theme.task.style : l.style; - final (li, fi) = _listIndents(indent); + var (li, fi) = _listIndents(indent); + // A list inside a quote (Write 02 §1.2): moved right by the quote's + // indent, with its bar and background. + var quote = ''; + if (isInQuote(b)) { + final q = theme.blockquote; + li += _tw(q.box.padding.left + (q.bar.isVisible ? q.bar.width : 0.0)); + quote = + '${q.bar.isVisible ? '\\brdrl${_border(q.bar, space: q.box.padding.left)}' : ''}' + '${_shading(q.box.background)}'; + } final content = _Rtf(); switch (b) { case ListItemBlock(:final kind, :final ordinal): @@ -682,7 +692,7 @@ final class _RtfWriter { final box = task ? theme.task.box : l.box; return _Unit.para( '\\s${_S.listParagraph}\\ql\\fi$fi\\li$li${_lineSpacing(style)}' - '\\ls$ls\\ilvl$indent${_chars(style)}', + '\\ls$ls\\ilvl$indent$quote${_chars(style)}', content.done(), before: first ? box.spaceBefore : l.itemSpacing, after: last ? math.max(l.itemSpacing, box.spaceAfter) : l.itemSpacing, diff --git a/packages/wonky_write_core/lib/src/export/txt_exporter.dart b/packages/wonky_write_core/lib/src/export/txt_exporter.dart index 5b8986c9..fb5b74d5 100644 --- a/packages/wonky_write_core/lib/src/export/txt_exporter.dart +++ b/packages/wonky_write_core/lib/src/export/txt_exporter.dart @@ -114,10 +114,29 @@ final class TxtExporter implements DocumentExporter { blocks.add((_table(cells, options.tables), false, true)); } } + // A list inside a quote (Write 02 §1.2): the items behind `> `, joined to + // the quote's lines by a `>` line like in Markdown. + final quoted = [ + for (final block in tree.blocks) + block is ListItemBlock || block is TaskBlock ? isInQuote(block) : false, + ]; + final inQuote = [for (final block in tree.blocks) isInQuote(block)]; final b = StringBuffer(); for (var i = 0; i < blocks.length; i++) { - if (i > 0) b.write(blocks[i - 1].$2 && blocks[i].$2 ? '\n' : '\n\n'); - b.write(blocks[i].$1); + if (i > 0) { + final lists = blocks[i - 1].$2 && blocks[i].$2; + b.write( + inQuote[i - 1] && inQuote[i] + ? (lists ? '\n' : '\n>\n') + : lists && !inQuote[i - 1] && !inQuote[i] + ? '\n' + : '\n\n', + ); + } + final text = blocks[i].$1; + b.write( + quoted[i] ? text.split('\n').map((l) => '> $l').join('\n') : text, + ); } b.write('\n'); final out = b.toString(); diff --git a/packages/wonky_write_core/lib/src/import/html/html_importer.dart b/packages/wonky_write_core/lib/src/import/html/html_importer.dart index ae62c087..6ca7c6f8 100644 --- a/packages/wonky_write_core/lib/src/import/html/html_importer.dart +++ b/packages/wonky_write_core/lib/src/import/html/html_importer.dart @@ -947,8 +947,10 @@ final class _HtmlWalker { void _heading(int level, dom.Element e, _Fmt fmt) { _flush(); _stack.add(_HeadingCtx(level)); + final start = _blocks.length; _children(e, fmt); - _flush(); + // A heading of only whitespace is still a (empty) heading line. + _flush(explicitEmpty: _blocks.length == start); _stack.removeLast(); } @@ -1066,7 +1068,7 @@ final class _HtmlWalker { void _list(dom.Element e, _Fmt fmt, CssDeclarations decls) { _flush(); final inner = _stack.lastOrNull; - if (inner is _ItemCtx && !inner.emitted && !_inQuote) { + if (inner is _ItemCtx && !inner.emitted) { _emitItem(inner, const []); } var ordered = e.localName == 'ol'; @@ -1188,7 +1190,7 @@ final class _HtmlWalker { } void _closeItem(_ItemCtx item) { - if (!item.emitted && _blocks.length == item.startBlocks && !_inQuote) { + if (!item.emitted && _blocks.length == item.startBlocks) { _emitItem(item, const []); } } @@ -1379,8 +1381,6 @@ final class _HtmlWalker { // ------------------------------------------------------------------------- // Emission - bool get _inQuote => _stack.any((c) => c is _QuoteCtx); - void _flush({bool explicitEmpty = false}) { if (_cellDepth > 0) { _buf.space(_Fmt.plain); @@ -1414,14 +1414,12 @@ final class _HtmlWalker { } if (qi >= 0) { if (ii > qi) { - _warnings.add( - ImportWarningKind.listInQuoteFlattened, - 'Lists inside quotes were converted to quote lines.', - blockIndex: _blocks.length, - ); - } else if (hi > qi || ii >= 0) { - _flattened(); + // A list inside the quote: quoted list items (Write 02 §1.2). + if (hi > ii) _flattened(); + _emitItem(_stack[ii] as _ItemCtx, runs); + return; } + if (hi > qi || ii >= 0) _flattened(); _blocks.add(QuoteBlock([runs])); return; } @@ -1452,10 +1450,18 @@ final class _HtmlWalker { blockIndex: _blocks.length, ); - DocBlock _itemBlock(_ItemCtx item, bool? checked, List runs) => - checked != null - ? TaskBlock(checked, item.indent, runs) - : ListItemBlock(item.kind, item.indent, runs); + DocBlock _itemBlock(_ItemCtx item, bool? checked, List runs) { + final quoted = _isQuoted(item); + return checked != null + ? TaskBlock(checked, item.indent, runs, inQuote: quoted) + : ListItemBlock(item.kind, item.indent, runs, inQuote: quoted); + } + + /// Whether [item]'s list is inside a blockquote. + bool _isQuoted(_ItemCtx item) { + final at = _stack.indexOf(item); + return at > 0 && _stack.take(at).any((c) => c is _QuoteCtx); + } void _emitItem(_ItemCtx item, List runs) { if (!item.emitted) { diff --git a/packages/wonky_write_core/lib/src/ir/doc_tree.dart b/packages/wonky_write_core/lib/src/ir/doc_tree.dart index f02c035c..7233cb42 100644 --- a/packages/wonky_write_core/lib/src/ir/doc_tree.dart +++ b/packages/wonky_write_core/lib/src/ir/doc_tree.dart @@ -127,10 +127,15 @@ final class ListItemBlock extends DocBlock { this.indent, this.runs, { this.ordinal = 1, + this.inQuote = false, super.deltaStart, super.deltaLength, }); + /// Whether the item is inside a blockquote; it belongs to the quote of the + /// [QuoteBlock]s and quoted items right before and after it. + final bool inQuote; + /// Bullet or ordered. final ListKindIr kind; @@ -151,10 +156,14 @@ final class TaskBlock extends DocBlock { this.checked, this.indent, this.runs, { + this.inQuote = false, super.deltaStart, super.deltaLength, }); + /// Whether the item is inside a blockquote (see [ListItemBlock.inQuote]). + final bool inQuote; + /// Whether the task is done. final bool checked; @@ -165,6 +174,14 @@ final class TaskBlock extends DocBlock { final List runs; } +/// Whether [block] is part of a blockquote: a [QuoteBlock] or a quoted list +/// or task item. Consecutive such blocks form one quote. +bool isInQuote(DocBlock? block) => switch (block) { + QuoteBlock() => true, + ListItemBlock(:final inQuote) || TaskBlock(:final inQuote) => inQuote, + _ => false, +}; + /// Consecutive blockquote lines. final class QuoteBlock extends DocBlock { /// Creates a quote. diff --git a/packages/wonky_write_core/lib/src/ir/doc_tree_builder.dart b/packages/wonky_write_core/lib/src/ir/doc_tree_builder.dart index 42c9acb7..fdb8d197 100644 --- a/packages/wonky_write_core/lib/src/ir/doc_tree_builder.dart +++ b/packages/wonky_write_core/lib/src/ir/doc_tree_builder.dart @@ -34,12 +34,16 @@ final class DocTreeBuilder { final anchors = AnchorIdGenerator(); // Ordinal state per indent level: (kind, count). final ordinals = {}; + // Whether the list run so far is inside a quote (a quoted and an + // unquoted list next to each other are two lists). + var quotedRun = false; var i = 0; final lines = doc.lines; while (i < lines.length) { final line = lines[i]; final f = line.format; - if (!f.isList) ordinals.clear(); + if (!f.isList || f.quoted != quotedRun) ordinals.clear(); + quotedRun = f.isList && f.quoted; if (line.isEmbedLine) { final embed = line.embed; switch (embed) { @@ -152,6 +156,7 @@ final class DocTreeBuilder { f.checked, indent, _runs(line), + inQuote: f.quoted, deltaStart: line.start, deltaLength: line.length, ), @@ -169,6 +174,7 @@ final class DocTreeBuilder { indent, _runs(line), ordinal: n, + inQuote: f.quoted, deltaStart: line.start, deltaLength: line.length, ), @@ -209,7 +215,12 @@ final class DocTreeToDelta { case HeadingBlock(:final level, runs: final rs): runs(rs); d.insert('\n', {WonkyAttr.header: level.clamp(1, 6)}); - case ListItemBlock(:final kind, :final indent, runs: final rs): + case ListItemBlock( + :final kind, + :final indent, + :final inQuote, + runs: final rs, + ): runs(rs); d.insert('\n', { WonkyAttr.list: kind == ListKindIr.ordered @@ -217,8 +228,14 @@ final class DocTreeToDelta { : ListKind.bullet.value, if (indent > 0) WonkyAttr.indent: indent.clamp(1, WonkyAttr.maxIndent), + if (inQuote) WonkyAttr.blockquote: true, }); - case TaskBlock(:final checked, :final indent, runs: final rs): + case TaskBlock( + :final checked, + :final indent, + :final inQuote, + runs: final rs, + ): runs(rs); d.insert('\n', { WonkyAttr.list: checked @@ -226,6 +243,7 @@ final class DocTreeToDelta { : ListKind.unchecked.value, if (indent > 0) WonkyAttr.indent: indent.clamp(1, WonkyAttr.maxIndent), + if (inQuote) WonkyAttr.blockquote: true, }); case QuoteBlock(:final lines): for (final l in lines) { diff --git a/packages/wonky_write_core/lib/src/markdown/markdown_exporter.dart b/packages/wonky_write_core/lib/src/markdown/markdown_exporter.dart index 1b8949e9..90ff9c8c 100644 --- a/packages/wonky_write_core/lib/src/markdown/markdown_exporter.dart +++ b/packages/wonky_write_core/lib/src/markdown/markdown_exporter.dart @@ -66,9 +66,7 @@ final class MarkdownExporter { continue; } final text = _block(block, isFirst: first, cols: cols); - if (!first) { - b.write(_isListLike(prev) && _isListLike(block) ? '\n' : '\n\n'); - } + if (!first) b.write(_separator(prev, block)); positions.add((start: b.length, length: text.length)); b.write(text); prev = block; @@ -80,6 +78,19 @@ final class MarkdownExporter { static bool _isListLike(DocBlock? b) => b is ListItemBlock || b is TaskBlock; + /// What goes between [prev] and [next]. Inside one quote (Write 02 §2.2): + /// items of a list are on consecutive lines, anything else is separated by + /// a blank `>` line. A quoted and an unquoted block are always separated by + /// a blank line, so the quote ends (no lazy continuation). + static String _separator(DocBlock? prev, DocBlock next) { + final prevQuoted = isInQuote(prev); + final nextQuoted = isInQuote(next); + final lists = _isListLike(prev) && _isListLike(next); + if (prevQuoted && nextQuoted) return lists ? '\n' : '\n>\n'; + if (prevQuoted != nextQuoted) return '\n\n'; + return lists ? '\n' : '\n\n'; + } + String _block( DocBlock block, { required bool isFirst, @@ -149,7 +160,9 @@ final class MarkdownExporter { kind: block is TaskBlock ? _Kind.task : _Kind.list, nested: indent > 0, ); - return item.split('\n').map((l) => '$pad$l').join('\n'); + // A list inside a quote: the same item behind the quote marker. + final quote = isInQuote(block) ? '> ' : ''; + return item.split('\n').map((l) => '$quote$pad$l').join('\n'); } String _verified( diff --git a/packages/wonky_write_core/lib/src/markdown/markdown_importer.dart b/packages/wonky_write_core/lib/src/markdown/markdown_importer.dart index c3fd2cc9..6ef349f5 100644 --- a/packages/wonky_write_core/lib/src/markdown/markdown_importer.dart +++ b/packages/wonky_write_core/lib/src/markdown/markdown_importer.dart @@ -345,11 +345,7 @@ final class MarkdownImporter { case 'blockquote': _quote(c, depth: depth + 1); case 'ul' || 'ol': - _warn( - ImportWarningKind.listInQuoteFlattened, - 'List inside a blockquote flattened', - ); - _flattenListInto(c, line); + _list(c, 0, inQuote: true); case 'h1' || 'h2' || 'h3' || 'h4' || 'h5' || 'h6': _flattenWarn(); _inlineBlock(c.children ?? const [], (runs) => QuoteBlock([runs])); @@ -380,26 +376,9 @@ final class MarkdownImporter { ); } - void _flattenListInto(md.Element list, void Function(List) line) { - for (final li - in (list.children ?? const []).whereType()) { - final inline = []; - for (final c in li.children ?? const []) { - if (c is md.Element && (c.tag == 'ul' || c.tag == 'ol')) { - if (inline.isNotEmpty) line(_brRule(_reader.readRuns(inline))); - inline.clear(); - _flattenListInto(c, line); - } else if (c is md.Element && c.tag == 'p') { - inline.addAll(c.children ?? const []); - } else { - inline.add(c); - } - } - if (inline.isNotEmpty) line(_brRule(_reader.readRuns(inline))); - } - } - - void _list(md.Element list, int depth) { + /// Imports a list; [inQuote] for a list inside a blockquote (its items + /// are quoted, Write 02 §1.2). + void _list(md.Element list, int depth, {bool inQuote = false}) { final ordered = list.tag == 'ol'; final start = list.attributes['start']; if (ordered && start != null && start != '1' && !_warnedStart) { @@ -501,13 +480,14 @@ final class MarkdownImporter { } final content = _brRule(InlineRun.merge(runs)); if (checked != null) { - _blocks.add(TaskBlock(checked, indent, content)); + _blocks.add(TaskBlock(checked, indent, content, inQuote: inQuote)); } else { _blocks.add( ListItemBlock( ordered ? ListKindIr.ordered : ListKindIr.bullet, indent, content, + inQuote: inQuote, ), ); } @@ -520,7 +500,7 @@ final class MarkdownImporter { _topBlock(o); } for (final n in nested) { - _list(n, depth + 1); + _list(n, depth + 1, inQuote: inQuote); } } } diff --git a/packages/wonky_write_core/lib/src/testing/generators.dart b/packages/wonky_write_core/lib/src/testing/generators.dart index baa8331a..ca864b42 100644 --- a/packages/wonky_write_core/lib/src/testing/generators.dart +++ b/packages/wonky_write_core/lib/src/testing/generators.dart @@ -164,7 +164,11 @@ final class DeltaGen { return attrs.isEmpty ? null : attrs; } - Map? _blockAttributes(int? prevListIndent) { + Map? _blockAttributes( + int? prevListIndent, { + bool prevInQuote = false, + bool prevListQuoted = false, + }) { final p = random.nextInt(100); if (p < 40) return null; if (p < 52) return {WonkyAttr.header: 1 + random.nextInt(6)}; @@ -175,13 +179,16 @@ final class DeltaGen { }; } final kinds = ListKind.values; - final maxIndent = prevListIndent == null + // Lists inside quotes: mostly right after a quote line or quoted item. + final quoted = random.nextInt(prevInQuote ? 2 : 8) == 0; + final maxIndent = prevListIndent == null || prevListQuoted != quoted ? 0 : (prevListIndent + 1).clamp(0, 8); final indent = random.nextInt(maxIndent + 1); return { WonkyAttr.list: kinds[random.nextInt(kinds.length)].value, if (indent > 0) WonkyAttr.indent: indent, + if (quoted) WonkyAttr.blockquote: true, }; } @@ -236,6 +243,8 @@ final class DeltaGen { final d = Delta(); final lines = 1 + random.nextInt(options.maxLines); int? prevListIndent; + var prevInQuote = false; + var prevListQuoted = false; for (var l = 0; l < lines; l++) { if (options.embeds && random.nextInt(8) == 0) { final e = embed(); @@ -245,9 +254,14 @@ final class DeltaGen { d.insert(e.toInsert(), attrs); d.insert('\n'); prevListIndent = null; + prevInQuote = false; continue; } - final block = _blockAttributes(prevListIndent); + final block = _blockAttributes( + prevListIndent, + prevInQuote: prevInQuote, + prevListQuoted: prevListQuoted, + ); final runs = options.emptyLines && random.nextInt(8) == 0 ? 0 : 1 + random.nextInt(options.maxRunsPerLine); @@ -257,6 +271,8 @@ final class DeltaGen { d.insert('\n', block); final f = BlockFormat.fromAttributes(block); prevListIndent = f.isList ? f.indent : null; + prevInQuote = f.inQuote; + prevListQuoted = f.quoted; } return d; } diff --git a/packages/wonky_write_core/test/export/quoted_list_export_test.dart b/packages/wonky_write_core/test/export/quoted_list_export_test.dart new file mode 100644 index 00000000..de868059 --- /dev/null +++ b/packages/wonky_write_core/test/export/quoted_list_export_test.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:wonky_write_core/wonky_write_core.dart'; + +import 'export_support.dart'; + +/// Exports of a list inside a quote (Write 02 §1.2, 07; T-19). +void main() { + const source = '> Said:\n>\n> - one\n> - two\n\n- outside\n'; + + test('Given a list in a quote when exporting text then the items are ' + 'behind "> " and the list outside is not', () async { + final a = await const TxtExporter().export( + input(md(source)), + const TxtExportOptions(), + ); + + expect(utf8.decode(a.bytes), '> Said:\n>\n> - one\n> - two\n\n- outside\n'); + }); + + test('Given a list in a quote when exporting RTF then the quoted items ' + 'have the quote bar and the outside item does not', () async { + final a = await const RtfExporter().export( + input(md(source)), + const RtfExportOptions(), + ); + final rtf = ascii.decode(a.bytes); + // The paragraph's group up to [text]: from the previous `\par` end. + String paragraphOf(String text) { + final at = rtf.indexOf(text); + final ends = RegExp(r'\\par(?![a-z])').allMatches(rtf.substring(0, at)); + return rtf.substring(ends.isEmpty ? 0 : ends.last.end, at); + } + + expect(paragraphOf('one'), contains(r'\brdrl')); + expect(paragraphOf('two'), contains(r'\brdrl')); + expect(paragraphOf('outside'), isNot(contains(r'\brdrl'))); + }); + + test('Given a list in a quote when exporting DOCX then the quoted items ' + 'are numbered with a left border', () async { + final a = await const DocxExporter().export( + input(md(source)), + const OfficeExportOptions(), + ); + final xml = utf8.decode( + DeterministicZip.decode(a.bytes)['word/document.xml']!, + ); + String paragraphOf(String text) { + final at = xml.indexOf('>$text<'); + return xml.substring(xml.lastIndexOf('', at), at); + } + + expect(paragraphOf('one'), contains('')); + expect(paragraphOf('one'), contains(' - a\n> - b\n', - [ - {'insert': 'a'}, - { - 'insert': '\n', - 'attributes': {'blockquote': true}, - }, - {'insert': 'b'}, - { - 'insert': '\n', - 'attributes': {'blockquote': true}, - }, - ], - {ImportWarningKind.listInQuoteFlattened}, - ), ( 'footnotes', 'Text[^1]\n\n[^1]: Note\n', diff --git a/packages/wonky_write_core/test/markdown/quoted_list_test.dart b/packages/wonky_write_core/test/markdown/quoted_list_test.dart new file mode 100644 index 00000000..2855a7c4 --- /dev/null +++ b/packages/wonky_write_core/test/markdown/quoted_list_test.dart @@ -0,0 +1,162 @@ +import 'package:test/test.dart'; +import 'package:wonky_write_core/wonky_write_core.dart'; + +import '../property/markdown_round_trip_test.dart' show checkRoundTrip; + +/// Lists inside blockquotes (Write 02 §1.2, §2.2; T-19): a list line with +/// `blockquote: true`. +void main() { + const quote = {'blockquote': true}; + Delta doc(List<(String, Map?)> lines) { + final d = Delta(); + for (final (text, attributes) in lines) { + if (text.isNotEmpty) d.insert(text); + d.insert('\n', attributes); + } + return d; + } + + final quotedList = doc([ + ('Intro', null), + ('Before the list', quote), + ('One', {'list': 'bullet', ...quote}), + ('Nested', {'list': 'bullet', 'indent': 1, ...quote}), + ('Two', {'list': 'bullet', ...quote}), + ('First', {'list': 'ordered', ...quote}), + ('Done', {'list': 'checked', ...quote}), + ('After the list', quote), + ('Outside', {'list': 'bullet'}), + ]); + + group('Given a quote with a paragraph, lists and tasks', () { + test('then the delta is valid', () { + expect( + const WonkyDeltaValidator().validate(quotedList).isValid, + isTrue, + reason: '${const WonkyDeltaValidator().validate(quotedList).issues}', + ); + }); + + test('then normalizing keeps the quote on the list lines', () { + final normalized = const WonkyDeltaNormalizer().normalize(quotedList); + + expect(normalized.document, quotedList); + expect(normalized.fixes, isEmpty); + }); + + test('then Markdown has one quote with the lists behind `>` and the ' + 'unquoted list after it', () { + final md = const MarkdownCodec().exportMarkdown(quotedList); + + expect( + md, + 'Intro\n' + '\n' + '> Before the list\n' + '>\n' + '> - One\n' + '> - Nested\n' + '> - Two\n' + '> 1. First\n' + '> - [x] Done\n' + '>\n' + '> After the list\n' + '\n' + '- Outside\n', + ); + }); + + test('then Markdown round-trips', () => checkRoundTrip(quotedList)); + + test('then HTML puts the paragraphs and lists in one blockquote', () { + final tree = const DocTreeBuilder().fromDelta(quotedList); + final html = const HtmlBodyWriter().blocks(tree.blocks); + + expect(html, contains('

Before the list

    ')); + expect(html, contains('
  • Nested')); + expect(html, contains('

    After the list

')); + expect( + '
'.allMatches(html), + hasLength(1), + reason: 'one quote', + ); + expect( + html.indexOf('Outside'), + greaterThan(html.indexOf('
')), + ); + }); + }); + + test('Given Markdown with a list in a quote then it imports as quoted ' + 'list items without warnings', () { + final result = const MarkdownCodec().importMarkdown('> - a\n> - b\n'); + + expect( + result.delta, + doc([ + ('a', {'list': 'bullet', ...quote}), + ('b', {'list': 'bullet', ...quote}), + ]), + ); + expect(result.warnings, isEmpty); + }); + + test('Given pasted HTML with a list in a blockquote then it imports as a ' + 'quote line and quoted list items', () { + final result = const HtmlImporter().importFragment( + '

Said:

  • a
    • b
' + '
  1. c

After

', + ); + + expect( + result.delta, + doc([ + ('Said:', quote), + ('a', {'list': 'bullet', ...quote}), + ('b', {'list': 'bullet', 'indent': 1, ...quote}), + ('c', {'list': 'ordered', ...quote}), + ('After', null), + ]), + ); + expect( + result.warnings.map((w) => w.kind), + isNot(contains(ImportWarningKind.listInQuoteFlattened)), + ); + }); + + test('Given the HTML export of a quoted list then importing it gives the ' + 'same document', () { + final html = const HtmlBodyWriter().blocks( + const DocTreeBuilder().fromDelta(quotedList).blocks, + ); + + expect(const HtmlImporter().importFragment(html).delta, quotedList); + }); + + test('Given an ordered list in a quote next to one outside then each is ' + 'numbered from 1', () { + final tree = const DocTreeBuilder().fromDelta( + doc([ + ('a', {'list': 'ordered', ...quote}), + ('b', {'list': 'ordered', ...quote}), + ('c', {'list': 'ordered'}), + ]), + ); + + expect( + [for (final b in tree.blocks.cast()) b.ordinal], + [1, 2, 1], + ); + }); + + test('Given a quote on a heading then it is still a conflict', () { + final d = doc([ + ('h', {'header': 1, ...quote}), + ]); + + expect( + const WonkyDeltaValidator().validate(d).issues.map((i) => i.code), + contains(ValidationCode.conflictingBlockAttributes), + ); + }); +} diff --git a/specs/04-auth-and-accounts.md b/specs/04-auth-and-accounts.md index 067e2855..81797530 100644 --- a/specs/04-auth-and-accounts.md +++ b/specs/04-auth-and-accounts.md @@ -21,8 +21,10 @@ cookie on the shared origin; desktop details in [01 §2.5](01-architecture.md). *Change email*. 4. On success → create `UserProfile` (see §3), set `writeDefaults.defaultThemeId` to the built-in default theme (built-in themes are global rows, see Write 06), create a welcome - document + welcome spreadsheet (from templates; (v2) + a welcome presentation once Slides - ships, M9 — all small and counted in the new user's 50 MB, Drive 03) → land on **Wonky + document (from the `write.welcome` template; small and counted in the new user's 50 MB, + Drive 03). Only Write gets starter content: no welcome spreadsheet or presentation is + created (T-19; the `sheets.welcome` template stays for spreadsheets older accounts got + from it) → land on **Wonky Write's home** (v2; `/` redirects there, 01 §2.4, v2 review Q2) — or on the app the sign-up started from when `next` points into it. diff --git a/specs/18-publications-and-submissions.md b/specs/18-publications-and-submissions.md index 9749c4c9..54545c49 100644 --- a/specs/18-publications-and-submissions.md +++ b/specs/18-publications-and-submissions.md @@ -113,7 +113,7 @@ Documents with a submission show, left of *Share* (Write 05 §3.2): │ Title* [ How we sped up our CI ×3 ] │ │ Summary [ A short look at … ] │ │ Tags [ dart ×] [ ci ×] [ + ] │ -│ Cover image [ (thumbnail) Change ] │ +│ Cover image [thumbnail] [⇪ Upload image] (Replace, Remove)│ │ Note to the moderators [ ] │ │ ⚠ 2 open suggestions aren't included. Review them first? │ │ Comments in the document stay in Wonky (moderators can see │ @@ -123,8 +123,19 @@ Documents with a submission show, left of *Share* (Write 05 §3.2): ``` - Fields are the publication's configuration (§8.1). First submit prefills: title = document - title (or the first heading), summary = the first paragraph (≤ 300 chars), cover = the first - image. Later submits start from the stored values. + title (or the first heading), summary = the first paragraph (≤ 300 chars); the cover is never + prefilled (below). Later submits start from the stored values. +- **Cover image** (T-19): uploaded in the dialog, not picked from the document. An image in the + text would be shown twice by blogs that put the cover above the post (and at full size). + The field (`ImageFieldSpec` of the form dialog, `wonky_app`) shows a 16:9 thumbnail (muted + box with *No cover* when empty), *Upload image* (*Replace* once set) and *Remove*, and the + hint "Shown with your post. It isn't added to the document." *Upload image* opens the image + picker (png, jpg, gif, webp; Write 04 §17.1), runs the storage pre-check and uploads the file + as an **image of the story's document** (`StoredFileKind.image`, the document's item: counted + in its owner's quota, readable by its moderators, deleted with it) without inserting it into + the text. *Submit* waits for a running upload. A stored cover (also one picked from the + document's images before T-19) shows as the thumbnail. A required cover without an image: + toast "Pick a cover image." - Open suggestions are sent as the original text (Write 07 §4.1 `suggestions: original`); the warning links to the first one. - *Submit* calls `submission.submit(itemUuid, fields, expectedRevision)`; it waits up to 10 s @@ -264,6 +275,11 @@ check the signature, reject timestamps more than 5 minutes off and drop repeated - `markdown` is the Write Markdown export (Write 07 §4) with suggestions as original, no front matter and no comments; `wonky-file://` images become asset URLs, `https://` images stay. +- `fields.coverImage` is the asset id of the cover, which is always in `assets` (and a + `submission_asset`, so the API key may download it). The cover is accepted when it is an + image in the text or a ready image file of the story's document (uploaded in the submit + dialog, §4.2); anything else is dropped, and a required cover that isn't one is refused + ("Upload a cover image."). - Asset URLs need the API key (§7.3). The blog should download them when it receives the webhook; they work as long as the image exists in Wonky. - No email addresses are sent. `wonkyUserId` is the profile uuid (for blocking, §7.3). @@ -366,7 +382,9 @@ submission, sends nothing). `get(itemUuid)` → `SubmissionView?` (viewer+); `submit(itemUuid, values, agreed)` (editor+, publication enabled, not blocked, not `declined`; `agreed` confirms changed terms): names a -version, exports that version's revision and sends it; `withdraw(itemUuid)`, +version, exports that version's revision and sends it; `values.coverImage` is the file uuid of +the cover (uploaded through `file.createUpload`/`completeUpload` as an image of the document, +§4.2, §7.1); `withdraw(itemUuid)`, `disconnect(itemUuid)`, `reconnect(itemUuid)` (editor+). Changes reach open editors as a `DriveChange` of kind `updated` for the item; Write's `SubmissionController` reloads on it. @@ -432,8 +450,9 @@ stored unencrypted (it must sign) with the same caveat as user AI keys; the priv `SubmissionTitleActions`, the submit dialog, `write.submission.*` commands, File menu items, which stay listed but disabled for documents without a submission). - **Tests**: server `test/unit/publications/` (signing vector of the guide, rules) and - `test/integration/platform/publications/` (flow, API over HTTP, owner endpoints); client - `wonky_app/test/publications/` and `wonky_write/test/features/publish/`. + `test/integration/platform/publications/` (flow, API over HTTP, owner endpoints, the uploaded + cover image in `submission_cover_test.dart`); client `wonky_app/test/publications/` and + `wonky_write/test/features/publish/`. - **Open**: light/dark goldens of the dialogs and chip; the E2E journey J-P1; moderators who sign up later get access only with the next submit or status change. - **Not planned** (only on a feature request): MCP tools (§9.3); Write `?comment=` deep links, diff --git a/specs/19-usage-analytics.md b/specs/19-usage-analytics.md index c50606c9..fe208aef 100644 --- a/specs/19-usage-analytics.md +++ b/specs/19-usage-analytics.md @@ -43,9 +43,9 @@ Sources: - The assistant's edits (`ActorKind.ai`) count for the person who asked; scripts and the system don't count. -- The **welcome content** (the welcome document and spreadsheet every account gets, - `write.welcome` / `sheets.welcome`) is not counted as created and does not make anyone an - editor. +- The **welcome content** (the welcome document every account gets and the welcome + spreadsheet older accounts got, `write.welcome` / `sheets.welcome`) is not counted as created + and does not make anyone an editor. - Each instance remembers which (user, day, app) keys it wrote (LRU of 50,000), so the hot paths cost a map lookup. Pings, opens and MCP calls also refresh `user_profile.lastSeenAt` (at most every 15 min per user). Edits don't, because they can run inside a transaction that locks the diff --git a/specs/wonky_write/02-document-model.md b/specs/wonky_write/02-document-model.md index b69b371f..3596a943 100644 --- a/specs/wonky_write/02-document-model.md +++ b/specs/wonky_write/02-document-model.md @@ -69,14 +69,20 @@ real lines). | `list` | `"bullet"` \| `"ordered"` | `- ` / `1. ` | `ListItemNode.unordered` / `.ordered` | | `list` | `"checked"` \| `"unchecked"` | `- [x] ` / `- [ ] ` | `TaskNode(isComplete)` | | `indent` | `1`–`8` | nested list (2/3-space indentation per level) | `ListItemNode.indent` / `TaskNode.indent`; **only valid with `list`** | +| `blockquote` + `list` | `true` next to a `list` value (+ `indent`) | `> - ` / `> 1. ` / `> - [ ] ` (a list inside a quote) | `ListItemNode` / `TaskNode` with node metadata `wonkyQuoted: true` (T-19) | The line-end `"\n"` may additionally carry `cmt_*` and `sug_*` attributes (not block attributes): `sug_` with `t:"ins"`/`t:"del"` on a `"\n"` represents a suggested line split/merge, and `t:"fmt"` with `before: {}` represents a suggested block-type change (e.g. paragraph → heading); `cmt_*` on `"\n"` lets a comment span whole lines. -Combinations: `header`, `blockquote`, `code-block`, `list` are mutually exclusive. Lists inside -quotes are not supported (not supported by super_editor) — import flattens them with a notice. +Combinations: `header`, `blockquote`, `code-block`, `list` are mutually exclusive, except +`blockquote` with `list`: a **list item inside a quote** (bullet, ordered or task, any indent; +`BlockFormat.quoted`). Consecutive quote lines and quoted list items form one quote. A quoted and +an unquoted list next to each other are two lists: an item's indent may be at most one deeper +than the previous list line's only when both are quoted or both are not (validator, normalizer +and the editor's schema guard), and ordered numbering restarts. Headings and code blocks can't +be in a quote; nested quotes are flattened to one level. ### 1.3 Block embeds @@ -125,7 +131,8 @@ client, server, importers and MCP tools. | Setext headings | `header` | | Inline HTML | stripped to text (except `
` → U+2028) | | HTML blocks | converted to text paragraphs (tags stripped) | - | Nested blockquotes / lists in quotes | flattened to one level | + | Nested blockquotes | flattened to one level | + | Lists in quotes | quoted list items (`blockquote` + `list`, §1.2) | | Footnotes, math, definition lists | kept as literal text | | Reference-style links/images | resolved to inline | | Loose vs tight lists | both → list lines (export always tight) | @@ -141,7 +148,9 @@ client, server, importers and MCP tools. - Lists: `-` for bullets, `1.` numbering incremented, 2 spaces per indent level for bullets, 3 for ordered. - Blockquote runs: consecutive `blockquote` lines → one `>` block, lines separated by `>` blank - lines (paragraphs). + lines (paragraphs). Quoted list items are the list's Markdown behind `> ` in the same block: + items of one list on consecutive lines, a `>` blank line between a paragraph and a list; a + quoted and an unquoted block are always separated by a blank line (the quote ends). - Code: ```` ```lang ```` fences. - Tables: pipes escaped in cells, alignment row from `align`. - Comments are dropped; suggestions are exported as *accepted* (default) or *original* diff --git a/specs/wonky_write/04-editor.md b/specs/wonky_write/04-editor.md index fe6f3859..6e363179 100644 --- a/specs/wonky_write/04-editor.md +++ b/specs/wonky_write/04-editor.md @@ -772,14 +772,42 @@ clients. quote nodes render as **one** visual quote. The theme's quote rule uses `BlockSelector("blockquote").after("blockquote")` to collapse the vertical padding between them and continue the left border. +- `WonkyParagraphComponentBuilder` paints the bar in the quote's left indent (a `CustomPaint` + around the paragraph component). The bar is **not a hit target** (its painter's `hitTest` + returns false): a hit-testable painter made every quote line absorb taps and clicks before + the interactor below the document layer saw them, so quote text could not be clicked, + selected or edited (T-19). Quote lines take the caret, selections, typing, formatting and + undo like any paragraph. - Applied with `write.format.blockType {type: quote}` (toggle), `> `, slash `/quote`, or the context menu's *Turn into*. - Enter, Backspace and Shift+Enter work as in §6. -- No nesting. Lists inside quotes are not supported: making a quote line a list removes the - quote type. +- **Lists inside quotes** (T-19; schema in [02](02-document-model.md) §1.2): bullet, numbered and + task items can be in a quote (`ListItemNode`/`TaskNode` with metadata `wonkyQuoted`). They sit + in the quote's indent with its bar (`WonkyListItemComponentBuilder`, `WonkyTaskComponentBuilder` + through `quoteBar`; their item spacing is inside the bar too) and its text style. Rules: + - In a quote line, `- `, `1. ` and `[ ] ` (and the list toggles / *Turn into*) make a list item + inside the quote; toggling the list off gives a quote line back. + - Quote on list items moves them into a quote; Quote on lines that are all in a quote takes + them out (list items stay list items). The Quote toggle is on for quoted items + (`selectionInQuote`). `> ` typed at the start of a list item moves it into a quote. + - Headings, code blocks and *Normal text* leave the quote. + - Enter in a quoted item adds a quoted item of the same type and indent (a new task is + unchecked); on an empty item it un-indents, then makes it a quote line; on an empty quote + line it leaves the quote. Backspace at the start of a quoted item un-indents, then makes it + a quote line (a Write keyboard action runs before super_editor's list actions, which would + make it a Normal paragraph). Tab / Shift+Tab indent within the quote. + - A quoted and an unquoted list are two lists (indent and numbering restart). +- No nested quotes. **AC-14.1** Three consecutive quote lines render with one continuous border. Export gives one `>` block with blank `>` separators ([02](02-document-model.md) §2.2). +**AC-14.2** Tapping or clicking any line of a single- or multi-line quote places the caret +there; double-click selects a word, typing, Enter and Mod+B work in it, and undo/redo restore +it. A list item between quote lines is edited the same way (T-19; widget tests in +`components/blockquote_test.dart`). +**AC-14.3** A quote of a paragraph and a bulleted list shows one unbroken bar along the paragraph +and the items; typing, Enter, Backspace, Tab and the block-type commands keep items in the quote +as above, and undo restores each change. `> - item` Markdown round-trips. ## 15. Code blocks diff --git a/specs/wonky_write/07-import-export-print.md b/specs/wonky_write/07-import-export-print.md index a805c65b..d8e762a3 100644 --- a/specs/wonky_write/07-import-export-print.md +++ b/specs/wonky_write/07-import-export-print.md @@ -232,7 +232,7 @@ context stack: | `img` | image embed (`data:` URIs → uploaded; `http(s)` kept as remote `src`, or downloaded when `downloadRemoteImages` (server only, SSRF-safe fetcher: https only, public IPs, ≤ 10 MB, 15 s)) | | `ul`/`ol`/`li` (nested) | `list` bullet/ordered with `indent` = nesting depth (max 8; deeper flattened) | | `li` starting with `input[type=checkbox]` or `☐/☑/☒` | `list: checked/unchecked` | -| `blockquote` (nested) | `blockquote` (flattened to one level, lists inside → paragraphs + warning) | +| `blockquote` (nested) | `blockquote` (flattened to one level; lists inside → quoted list items, `blockquote` + `list`, 02 §1.2) | | `hr` | divider | | `table` | table embed: `thead`/`th` row or first row = header; `colspan`/`rowspan` → content in first cell, others empty (warning); nested tables → text; `align`/`text-align` → column align | | `figure > img + figcaption` | image with `title` = caption text | @@ -905,7 +905,8 @@ MCP mapping ([../09-mcp.md](../09-mcp.md) §4): | Bullet / ordered lists (nesting) | ✓ | — | ✓ | ✓ | ✓ | ✓ | | List start ≠ 1 | ≈ restart | — | ≈ | ≈ | ≈ | ≈ | | Task lists | ✓ | — | ✓ | ≈ (glyph/checkbox) | ≈ | ≈ | -| Blockquote | ✓ (flattened) | — | ✓ (flattened) | ≈ (Quote style) | ≈ | ≈ | +| Blockquote | ✓ (nesting flattened) | — | ✓ (nesting flattened) | ≈ (Quote style) | ≈ | ≈ | +| Lists inside quotes | ✓ | — | ✓ | ≈ (plain list items) | ≈ | ≈ | | Code block (+ language) | ✓ | — | ✓ | ≈ (no language) | ≈ | ≈ | | Images | ✓ (URL / zip) | — | ✓ | ✓ (EMF/WMF ✗) | ✓ | ✓ (png/jpeg) | | Image captions | title | — | figcaption | ✗ | ✗ | ✗ | @@ -929,6 +930,7 @@ MCP mapping ([../09-mcp.md](../09-mcp.md) §4): | Images | link/zip | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `[image]` | | Tables (header repeat) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ≈ | | Task checkboxes | ✓ | ✓ | ✓ | ≈ glyph | ≈ glyph | ≈ glyph | ✓ | ✓ `[ ]` | +| Lists inside quotes | ✓ `> - ` | ✓ bar + indent | ✓ `
    ` | ≈ bar + indent | ≈ quote style | ≈ bar + indent | ✓ | ✓ `> - ` | | Links / mentions | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | URL in parens | | Page setup / header & footer | ✗ | ✓ | ≈ (`@page` size/margins) | ✓ | ✓ | ✓ | ✗ | ✗ | | TOC | ✗ | ✓ (opt) | ✓ (opt) | ✓ (field) | ✓ (opt) | ✗ | nav ✓ | ✗ | diff --git a/specs/wonky_write/09-mcp-and-ai.md b/specs/wonky_write/09-mcp-and-ai.md index fc338e4c..903c837f 100644 --- a/specs/wonky_write/09-mcp-and-ai.md +++ b/specs/wonky_write/09-mcp-and-ai.md @@ -158,7 +158,7 @@ except theme library tools requires) and outputs. Types: `int`, `str`, `bool`, ` Output: `{document_uuid, title, revision, doc_length, format, content (string, or Delta JSON array for "delta", or outline array), blocks?: [{index, type: "paragraph"|"heading"|"bullet"| -"ordered"|"task"|"quote"|"code"|"image"|"divider"|"table", level?, indent?, checked?, language?, +"ordered"|"task"|"quote"|"code"|"image"|"divider"|"table", level?, indent?, checked?, quoted?, language?, delta_start, delta_length, md_start, md_length, preview}], front_matter?, theme_uuid, layout: "pages"|"pageless", truncated, next_cursor?}`. @@ -237,7 +237,9 @@ embeds in the range are skipped. #### `write.set_block_type` Input: `range: Range` (all lines touched), `type: "paragraph"|"heading"|"bullet"|"ordered"| "task"|"quote"|"code"`, `level?: 1–6` (heading), `indent?: 0–8` (lists/tasks), `checked?: bool` -(task), `language?: str` (code), MutationOptions. Converting lines to `code` merges them into one +(task), `quoted?: bool` (lists/tasks: inside a quote; omitted keeps whether the line is in a +quote, so a quote line becomes a quoted list item, T-19), +`language?: str` (code), MutationOptions. Converting lines to `code` merges them into one code block; converting a code block to paragraphs splits at its lines. #### `write.set_markdown` diff --git a/wonky_server/lib/src/platform/drive/templates/builtin_templates.dart b/wonky_server/lib/src/platform/drive/templates/builtin_templates.dart index 18422691..039ce494 100644 --- a/wonky_server/lib/src/platform/drive/templates/builtin_templates.dart +++ b/wonky_server/lib/src/platform/drive/templates/builtin_templates.dart @@ -241,6 +241,9 @@ class BuiltinTemplate { /// hard line breaks (one block, several lines); keep the trailing spaces. abstract final class BuiltinTemplates { static const welcomeDocumentKey = 'write.welcome'; + + /// No longer created at sign-up (T-19); kept so spreadsheets that older + /// accounts got from it still resolve their template. static const welcomeSpreadsheetKey = 'sheets.welcome'; static BuiltinTemplate? byUuid(UuidValue uuid) { diff --git a/wonky_server/lib/src/platform/publications/submission_service.dart b/wonky_server/lib/src/platform/publications/submission_service.dart index 1638b58e..15ff3084 100644 --- a/wonky_server/lib/src/platform/publications/submission_service.dart +++ b/wonky_server/lib/src/platform/publications/submission_service.dart @@ -226,10 +226,19 @@ abstract final class SubmissionService { if (utf8.encode(markdown).length > PublicationRules.maxMarkdownBytes) { throw Errors.tooLarge('This story is too long to submit (over 5 MB).'); } - final cover = - clean.coverImage != null && assets.containsKey(clean.coverImage) - ? clean.coverImage - : null; + // The cover is uploaded in the submit dialog as an image of the document + // without being in its text (specs/18 §4.2), or (older submits) one of + // the document's images. Either way it is an asset of the submission, so + // the API key may download it. + final coverFile = clean.coverImage == null + ? null + : assets[clean.coverImage] ?? + await _coverOfItem(session, item, clean.coverImage!); + if (coverFile != null) assets[coverFile.fileUuid] = coverFile; + final cover = coverFile?.fileUuid; + if (cover == null && p.fields.coverImage == PublicationFieldMode.required) { + throw Errors.invalid('Upload a cover image.'); + } final stored = clean.copyWith(coverImage: cover); final submitter = (await UserService.summaries(session, [ actor.userId, @@ -354,6 +363,24 @@ abstract final class SubmissionService { } /// The images of [markdown] the submitter may read, by file uuid. + /// The cover image [fileUuid] when it is a ready image file of [item] + /// (uploaded in the submit dialog); null otherwise, so a submit can't + /// hand the publication someone else's file. + static Future _coverOfItem( + Session session, + DriveItem item, + UuidValue fileUuid, + ) async { + final file = await FileService.byUuid(session, fileUuid); + if (file == null || + file.itemId != item.id || + file.kind != StoredFileKind.image || + file.status != StoredFileStatus.ready) { + return null; + } + return file; + } + static Future> _assets( Session session, Actor actor, diff --git a/wonky_server/lib/src/platform/users/account_service.dart b/wonky_server/lib/src/platform/users/account_service.dart index 37772752..8e8349f7 100644 --- a/wonky_server/lib/src/platform/users/account_service.dart +++ b/wonky_server/lib/src/platform/users/account_service.dart @@ -20,7 +20,8 @@ import 'user_service.dart'; abstract final class AccountService { // --------------------------------------------------------------- welcome - /// Creates the welcome document and spreadsheet once per account. + /// Creates the welcome document once per account. Only Write gets starter + /// content: no welcome spreadsheet or presentation (specs/04 §1.1). static Future ensureWelcomeContent(Session session, Actor actor) async { final profile = await UserService.requireProfile(session, actor.userId); if (profile.welcomeContentAt != null) return; @@ -39,16 +40,6 @@ abstract final class AccountService { name: doc.name, templateUuid: doc.uuid, ); - final sheet = BuiltinTemplates.byKey( - BuiltinTemplates.welcomeSpreadsheetKey, - ); - await DriveService.createSpreadsheet( - session, - actor, - name: sheet.name, - templateUuid: sheet.uuid, - timeZone: profile.timeZone ?? 'UTC', - ); } on Exception catch (e, st) { session.log( 'Welcome content for user ${actor.userId} failed: $e', diff --git a/wonky_server/test/integration/platform/publications/submission_cover_test.dart b/wonky_server/test/integration/platform/publications/submission_cover_test.dart new file mode 100644 index 00000000..365f3798 --- /dev/null +++ b/wonky_server/test/integration/platform/publications/submission_cover_test.dart @@ -0,0 +1,147 @@ +@Tags(['integration']) +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:serverpod/serverpod.dart'; +import 'package:test/test.dart'; +import 'package:wonky_server/src/platform/common/models.dart'; +import 'package:wonky_server/src/platform/publications/publication_rules.dart'; +import 'package:wonky_server/src/web/routes/publication_api_route.dart'; + +import '../../../support/publications/publication_harness.dart'; +import '../../../support/test_drive.dart'; +import '../../../support/test_files.dart'; +import '../../../support/test_users.dart'; +import '../../../support/wonky_serverpod.dart'; + +/// A 1×1 PNG. +final _png = Uint8List.fromList( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + ), +); + +/// The cover image uploaded in the submit dialog (specs/18 §4.2, T-19): an +/// image file of the document that is not in its text. +void main() { + withWonkyServerpod('Given a publication that requires a cover image', ( + sessionBuilder, + endpoints, + ) { + final h = PublicationHarness(); + final client = HttpClient(); + late Uri base; + late TestUser owner; + late TestUser anna; + late TestPublication pub; + late UuidValue doc; + + setUpAll(() async { + final pod = sessionBuilder.build().serverpod; + pod.webServer.addRoute(PublicationApiRoute(pod: pod), '/api/pub/v1/**'); + if (!pod.webServer.running) await pod.webServer.start(); + base = Uri.parse('http://localhost:${pod.webServer.port}/api/pub/v1/'); + }); + tearDownAll(() => client.close(force: true)); + + setUp(() async { + h.install(); + owner = await TestUsers.create(sessionBuilder, name: 'Owner'); + anna = await TestUsers.create(sessionBuilder, name: 'Anna'); + pub = await h.create( + endpoints, + owner, + input: PublicationHarness.input( + fields: PublicationRules.defaultFields().copyWith( + coverImage: PublicationFieldMode.required, + ), + ), + ); + doc = await h.start(endpoints, anna, pub); + }); + tearDown(h.uninstall); + + /// Uploads [_png] as an image of [itemUuid], like the submit dialog. + Future uploadImage(UuidValue itemUuid) async { + final fileUuid = const Uuid().v7obj(); + await endpoints.file.createUpload( + anna.endpointSession, + fileUuid, + itemUuid, + 'cover.png', + 'image/png', + _png.length, + ); + await TestFiles.putBytes(sessionBuilder.build(), fileUuid, _png); + await endpoints.file.completeUpload(anna.endpointSession, fileUuid); + return fileUuid; + } + + Future submit(UuidValue? cover) => + endpoints.submission.submit( + anna.endpointSession, + doc, + PublicationHarness.values( + title: 'Faster CI', + ).copyWith(coverImage: cover), + false, + ); + + test('when submitting with an uploaded cover then the webhook names it ' + 'and lists it as an asset, and the API serves its bytes', () async { + final cover = await uploadImage(doc); + + final view = await submit(cover); + + final json = h.blog.ofType('submission.submitted').single.json; + final assetId = 'ast_${cover.uuid}'; + expect(view.values.coverImage, cover); + expect((json['fields']! as Map)['coverImage'], assetId); + final assets = (json['assets']! as List).cast>(); + expect(assets.single['id'], assetId); + expect(assets.single['name'], 'cover.png'); + expect(assets.single['type'], 'image/png'); + expect(assets.single['bytes'], _png.length); + // Not in the text: blogs that show the cover don't show it twice. + expect(json['markdown'], isNot(contains(assetId))); + + final req = await client.getUrl(base.resolve('assets/$assetId')); + req.headers.set('authorization', 'Bearer ${pub.apiKey}'); + final res = await req.close(); + final bytes = await res.fold>([], (a, b) => a..addAll(b)); + expect(res.statusCode, 200); + expect(bytes, _png); + }); + + test('when submitting without a cover then it is refused', () async { + await expectLater(submit(null), throwsA(isA())); + expect(h.blog.received, isEmpty); + }); + + test('when the cover is an image of another document then it is ' + 'refused', () async { + final other = await TestDrive.document(endpoints, anna, name: 'Other'); + final cover = await uploadImage(other.uuid); + + await expectLater(submit(cover), throwsA(isA())); + expect(h.blog.received, isEmpty); + }); + + test('when submitting again with a new cover then the new one is sent ' + 'and stored', () async { + await submit(await uploadImage(doc)); + final second = await uploadImage(doc); + h.blog.received.clear(); + + final view = await submit(second); + + final json = h.blog.ofType('submission.submitted').single.json; + expect(view.values.coverImage, second); + expect((json['fields']! as Map)['coverImage'], 'ast_${second.uuid}'); + expect(json['sequence'], 2); + }); + }); +} diff --git a/wonky_server/test/integration/platform/user_endpoint_test.dart b/wonky_server/test/integration/platform/user_endpoint_test.dart index 5a1ad17e..0d627e14 100644 --- a/wonky_server/test/integration/platform/user_endpoint_test.dart +++ b/wonky_server/test/integration/platform/user_endpoint_test.dart @@ -79,30 +79,37 @@ void main() { 50, ); - expect(page.items.map((i) => i.name).toList()..sort(), [ + expect(page.items.map((i) => i.name), [ 'New here? Read me first', - 'Welcome to Wonky Sheets', ], reason: 'round $round'); } }, ); test( - 'when calling getMe then the welcome spreadsheet is created', + 'when calling getMe then only the Write welcome document is created', () async { + // No welcome spreadsheet or presentation (T-19, specs/04 §1.1). final anna = await TestUsers.create(sessionBuilder, name: 'Anna'); await endpoints.user.getMe(anna.endpointSession); final page = await endpoints.drive.list( anna.endpointSession, null, - DriveListFilter(kinds: [DriveItemKind.spreadsheet]), + DriveListFilter(), DriveSort(), null, 50, ); - expect(page.items.map((i) => i.name), ['Welcome to Wonky Sheets']); + expect(page.items.map((i) => (i.kind, i.name)), [ + (DriveItemKind.document, 'New here? Read me first'), + ]); + expect( + await Spreadsheet.db.count(sessionBuilder.build()), + 0, + reason: 'no spreadsheet row either', + ); }, ); diff --git a/wonky_server/test/integration/write/mcp/write_tools_test.dart b/wonky_server/test/integration/write/mcp/write_tools_test.dart index 2fd00a45..4e112e60 100644 --- a/wonky_server/test/integration/write/mcp/write_tools_test.dart +++ b/wonky_server/test/integration/write/mcp/write_tools_test.dart @@ -242,6 +242,31 @@ void main() { }, ); + test( + 'when making a quote line a bullet then it is a list item inside the ' + 'quote, and quoted false takes it out', + () async { + Future set(Map args) => + mcp.call('write.set_block_type', { + 'document_uuid': docOf(doc), + 'range': {'type': 'quote', 'quote': 'Gamma'}, + ...args, + }); + + await set({'type': 'quote'}); + await set({'type': 'bullet'}); + expect( + await _markdown(endpoints, anna, doc), + contains('> - Gamma delta.'), + ); + + await set({'type': 'bullet', 'quoted': false}); + final markdown = await _markdown(endpoints, anna, doc); + expect(markdown, contains('- Gamma delta.')); + expect(markdown, isNot(contains('> - Gamma'))); + }, + ); + test( 'when inserting and updating a table then the table changes', () async { diff --git a/wonky_write/lib/features/editor/commands/editor_command_catalog.dart b/wonky_write/lib/features/editor/commands/editor_command_catalog.dart index 5ff8bc7e..8b99018e 100644 --- a/wonky_write/lib/features/editor/commands/editor_command_catalog.dart +++ b/wonky_write/lib/features/editor/commands/editor_command_catalog.dart @@ -98,7 +98,10 @@ List editorCommandCatalog() { bindings: bindings, requiredRole: Role.commenter, enabled: (t, _) => t.commands.canChangeBlockType(), - checked: (t, _) => t.commands.selectedBlockKind == kind, + // Quote is on for list items inside a quote too. + checked: (t, _) => kind == BlockKind.quote + ? t.commands.selectionInQuote + : t.commands.selectedBlockKind == kind, run: (t, _) => _result(t.commands.setBlockType(kind)), ); diff --git a/wonky_write/lib/features/editor/commands/write_editor_commands.dart b/wonky_write/lib/features/editor/commands/write_editor_commands.dart index 4948b264..8b4c5323 100644 --- a/wonky_write/lib/features/editor/commands/write_editor_commands.dart +++ b/wonky_write/lib/features/editor/commands/write_editor_commands.dart @@ -372,6 +372,13 @@ class WriteEditorCommands { return kinds.length == 1 ? kinds.single : null; } + /// Whether every selected text block is in a quote (a quote line or a + /// quoted list item): the Quote toggle is on. + bool get selectionInQuote { + final blocks = _selectedBlocks().where((b) => b.isText).toList(); + return blocks.isNotEmpty && blocks.every((b) => isInQuoteNode(b.node)); + } + bool canChangeBlockType() => canEdit && _selectedBlocks().any((b) => b.isText); @@ -401,28 +408,57 @@ class WriteEditorCommands { final target = isToggle && textBlocks.every((b) => kindOf(b) == kind) ? BlockKind.paragraph : kind; + // Lists inside quotes (§14): Quote on lines that are all in a quote + // takes them out of it (list items stay list items); Quote on list items + // moves them into a quote; list kinds keep the quote a line is in, and + // toggling a list off inside a quote leaves a quote line. + const listKinds = { + BlockKind.bulleted, + BlockKind.numbered, + BlockKind.checklist, + }; + final unquote = + isToggle && + kind == BlockKind.quote && + textBlocks.every((b) => isInQuoteNode(b.node)); + final listOff = listKinds.contains(kind) && target == BlockKind.paragraph; final language = lastCodeLanguage; - final change = Delta(); - var offset = 0; - for (final block in textBlocks) { - change.retain(block.start - offset); - offset = block.start; - final delta = controller.mirror.entryAt(block.index).delta; - final current = kindOf(block); + Map attributesOf(BlockRef block) { final node = block.node; - final currentIndent = node is ListItemNode - ? node.indent - : node is TaskNode - ? node.indent - : 0; + final isItem = node is ListItemNode || node is TaskNode; + final inQuote = isInQuoteNode(node); + if (unquote) { + return isItem ? {DeltaKeys.blockquote: null} : {..._clearBlock}; + } + if (kind == BlockKind.quote && isItem) { + return {DeltaKeys.blockquote: true}; + } + final current = kindOf(block); + final currentIndent = switch (node) { + ListItemNode(:final indent) || TaskNode(:final indent) => indent, + _ => 0, + }; final attributes = _blockAttributesFor( - target, + listOff && inQuote ? BlockKind.quote : target, current, currentIndent, node, language, ); + if (listKinds.contains(target) && inQuote) { + attributes[DeltaKeys.blockquote] = true; + } + return attributes; + } + + final change = Delta(); + var offset = 0; + for (final block in textBlocks) { + change.retain(block.start - offset); + offset = block.start; + final delta = controller.mirror.entryAt(block.index).delta; + final attributes = attributesOf(block); for (final op in delta.operations) { final data = op.data; if (data is! String) { diff --git a/wonky_write/lib/features/editor/components/wonky_components.dart b/wonky_write/lib/features/editor/components/wonky_components.dart index f99d9959..52dfb8a1 100644 --- a/wonky_write/lib/features/editor/components/wonky_components.dart +++ b/wonky_write/lib/features/editor/components/wonky_components.dart @@ -689,6 +689,76 @@ class WonkyTableComponentBuilder implements ComponentBuilder { // ----------------------------------------------------------------------------- // Code blocks and quotes (Write 04 §14, §15). +/// The space above and below each quote line (px at 100 %), painted over by +/// the quote bar. +const quoteLineSpacing = 2.0; + +/// A part of a quote (a quote line, or a list or task item inside a quote, +/// Write 04 §14): [child] with the quote bar in the stylesheet's quote +/// indent left of it, and [spacing] (px at 100 %) above and below inside +/// the bar, so the bars of the quote's parts touch. +Widget quoteBar( + BuildContext context, { + required EditorTheme theme, + required double zoom, + required double spacing, + required Widget child, +}) => Semantics( + label: writeL10n(context).editorQuoteLabel, + child: CustomPaint( + painter: _QuoteBarPainter( + color: theme.quoteBarColor, + width: theme.quoteBarWidth * zoom, + // The stylesheet indents quotes by bar width + 12 px; the bar sits in + // that indent, left of the text. + gap: 12 * zoom, + ), + child: Padding( + padding: EdgeInsets.symmetric(vertical: spacing * zoom), + child: child, + ), + ), +); + +/// super_editor's list items; inside a quote with the quote bar. +class WonkyListItemComponentBuilder implements ComponentBuilder { + const WonkyListItemComponentBuilder({ + required this.document, + required this.theme, + required this.zoom, + }); + + final Document document; + final EditorTheme theme; + final double zoom; + static const _inner = ListItemComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + ) => _inner.createViewModel(document, node); + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel viewModel, + ) { + final component = _inner.createComponent(componentContext, viewModel); + if (component == null || + !isQuotedListNode(document.getNodeById(viewModel.nodeId))) { + return component; + } + return quoteBar( + componentContext.context, + theme: theme, + zoom: zoom, + spacing: theme.listItemSpacing, + child: component, + ); + } +} + /// Paragraph components with Wonky chrome: code blocks get a language chip /// and a copy button; quote lines get a continuous left bar. class WonkyParagraphComponentBuilder implements ComponentBuilder { @@ -736,18 +806,12 @@ class WonkyParagraphComponentBuilder implements ComponentBuilder { ); if (component == null) return null; if (blockType == blockquoteAttribution) { - return Semantics( - label: writeL10n(componentContext.context).editorQuoteLabel, - child: CustomPaint( - painter: _QuoteBarPainter( - color: theme.quoteBarColor, - width: theme.quoteBarWidth * zoom, - // The stylesheet indents quotes by bar width + 12 px; the bar - // sits in that indent, left of the text. - gap: 12 * zoom, - ), - child: component, - ), + return quoteBar( + componentContext.context, + theme: theme, + zoom: zoom, + spacing: quoteLineSpacing, + child: component, ); } if (blockType == codeAttribution) { @@ -789,6 +853,13 @@ class _QuoteBarPainter extends CustomPainter { ); } + /// The bar is not a hit target. A [CustomPaint] whose painter does not + /// answer counts as hit, which made every quote line absorb taps and + /// clicks before the interactor below the document layer saw them: quote + /// text could not be clicked, selected or edited (T-19). + @override + bool hitTest(Offset position) => false; + @override bool shouldRepaint(_QuoteBarPainter oldDelegate) => oldDelegate.color != color || @@ -1017,6 +1088,9 @@ class WonkyTaskComponentBuilder implements ComponentBuilder { WonkyTaskComponentBuilder( this._editor, { required this.onToggle, + required this.theme, + this.zoom = 1, + this.document, this.editable = true, }) : _inner = TaskComponentBuilder(_editor); @@ -1025,6 +1099,13 @@ class WonkyTaskComponentBuilder implements ComponentBuilder { final void Function(String nodeId) onToggle; final bool editable; + /// For the quote bar of tasks inside a quote. + final EditorTheme theme; + final double zoom; + + /// The document shown (a suggestion preview's); the editor's when null. + final Document? document; + Editor get editor => _editor; @override @@ -1047,11 +1128,22 @@ class WonkyTaskComponentBuilder implements ComponentBuilder { if (component == null || viewModel is! TaskComponentViewModel) { return component; } - return Semantics( + final task = Semantics( checked: viewModel.isComplete, label: writeL10n(componentContext.context).editorCheckboxLabel, child: component, ); + final shown = document ?? _editor.document; + if (!isQuotedListNode(shown.getNodeById(viewModel.nodeId))) { + return task; + } + return quoteBar( + componentContext.context, + theme: theme, + zoom: zoom, + spacing: theme.listItemSpacing, + child: task, + ); } } diff --git a/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart b/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart index 27b05a61..a943ae2d 100644 --- a/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart +++ b/wonky_write/lib/features/editor/keyboard/editor_keyboard.dart @@ -6,6 +6,7 @@ import '../applier/delta_applier.dart'; import '../commands/write_editor_commands.dart'; import '../controller/editor_types.dart'; import '../controller/write_editor_controller.dart'; +import '../mapping/wonky_schema.dart' show isQuotedListNode; import '../model/document_queries.dart'; /// Handles a key event before the editor; returns `true` when handled. @@ -97,7 +98,40 @@ List wonkyKeyboardActions({ collapseSelectionWhenEscIsPressed, }; final defaults = ime ? defaultImeKeyboardActions : defaultKeyboardActions; - return [handler, ...defaults.where((action) => !removed.contains(action))]; + return [ + handler, + _backspaceInQuotedListItem, + ...defaults.where((action) => !removed.contains(action)), + ]; +} + +/// Backspace at the start of a list or task item inside a quote: through +/// [DeleteUpstreamRequest], so Write's handler un-indents it or makes it a +/// quote line (Write 04 §14). super_editor's own list actions run their +/// commands directly and would make it a Normal paragraph. +ExecutionInstruction _backspaceInQuotedListItem({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + final selection = editContext.composer.selection; + if (selection == null || !selection.isCollapsed) { + return ExecutionInstruction.continueExecution; + } + final position = selection.extent.nodePosition; + final node = editContext.document.getNodeById(selection.extent.nodeId); + if (!isQuotedListNode(node) || + position is! TextNodePosition || + position.offset != 0) { + return ExecutionInstruction.continueExecution; + } + editContext.editor.execute([const DeleteUpstreamRequest()]); + return ExecutionInstruction.haltExecution; } bool _builtInShortcut( diff --git a/wonky_write/lib/features/editor/mapping/delta_node_codec.dart b/wonky_write/lib/features/editor/mapping/delta_node_codec.dart index b0be955f..80ce9b58 100644 --- a/wonky_write/lib/features/editor/mapping/delta_node_codec.dart +++ b/wonky_write/lib/features/editor/mapping/delta_node_codec.dart @@ -17,8 +17,8 @@ const _deepEquality = DeepCollectionEquality(); /// |---|---| /// | paragraph / `header` / `blockquote` | [ParagraphNode] with block type | /// | consecutive `code-block` lines of one language | one [ParagraphNode] (`codeAttribution`) whose text contains `\n`; language in [WonkyNodeMetadata.codeLanguage] | -/// | `list: bullet/ordered` (+ `indent`) | [ListItemNode] | -/// | `list: checked/unchecked` (+ `indent`) | [TaskNode] | +/// | `list: bullet/ordered` (+ `indent`, `blockquote`) | [ListItemNode] ([WonkyNodeMetadata.quoted] inside a quote) | +/// | `list: checked/unchecked` (+ `indent`, `blockquote`) | [TaskNode] (same) | /// | `image` / `divider` / `table` embed + `\n` | [ImageNode] / [HorizontalRuleNode] / [TableBlockNode] (raw embed in [WonkyNodeMetadata.embed]) | /// /// Soft line breaks (U+2028) are `\n` inside non-code node text. Overlay @@ -110,12 +110,14 @@ class WonkyDeltaSerializer implements DeltaSerializer { ? 'ordered' : 'bullet', if (node.indent > 0) DeltaKeys.indent: node.indent, + if (isQuotedListNode(node)) DeltaKeys.blockquote: true, }; } if (node is TaskNode) { return { DeltaKeys.list: node.isComplete ? 'checked' : 'unchecked', if (node.indent > 0) DeltaKeys.indent: node.indent, + if (isQuotedListNode(node)) DeltaKeys.blockquote: true, }; } if (node is ParagraphNode) { @@ -348,6 +350,11 @@ class WonkyDeltaParser { final indent = rawIndent is int ? rawIndent.clamp(0, DeltaKeys.maxIndent) : 0; + // A list item inside a quote (Write 02 §1.2). + final listMetadata = { + ...metadata, + if (end?[DeltaKeys.blockquote] == true) WonkyNodeMetadata.quoted: true, + }; if (list == 'bullet' || list == 'ordered') { return ListItemNode( id: id, @@ -356,7 +363,7 @@ class WonkyDeltaParser { : ListItemType.unordered, text: text, indent: indent, - metadata: metadata, + metadata: listMetadata, ); } if (list == 'checked' || list == 'unchecked') { @@ -365,7 +372,7 @@ class WonkyDeltaParser { text: text, isComplete: list == 'checked', indent: indent, - metadata: metadata, + metadata: listMetadata, ); } diff --git a/wonky_write/lib/features/editor/mapping/wonky_schema.dart b/wonky_write/lib/features/editor/mapping/wonky_schema.dart index 1576656a..2ceecb77 100644 --- a/wonky_write/lib/features/editor/mapping/wonky_schema.dart +++ b/wonky_write/lib/features/editor/mapping/wonky_schema.dart @@ -62,6 +62,9 @@ abstract final class WonkyNodeMetadata { /// Language of a code block node (`""` = plain text). Shared with the /// vendored super_editor Quill serializer (patch SE-1). static const codeLanguage = codeLanguageMetadataKey; + + /// `true` on a list or task item inside a quote (Write 04 §14). + static const quoted = 'wonkyQuoted'; } /// Heading block type attributions by level (index 0 = level 1). @@ -96,3 +99,15 @@ String codeLanguageOf(DocumentNode node) { final language = node.getMetadataValue(WonkyNodeMetadata.codeLanguage); return language is String ? language : ''; } + +/// Whether [node] is a list or task item inside a quote (`blockquote: true` +/// next to `list`, Write 02 §1.2). +bool isQuotedListNode(DocumentNode? node) => + (node is ListItemNode || node is TaskNode) && + node!.getMetadataValue(WonkyNodeMetadata.quoted) == true; + +/// Whether [node] is part of a quote: a quote line or a quoted list item. +bool isInQuoteNode(DocumentNode? node) => + isQuotedListNode(node) || + (node is ParagraphNode && + node.getMetadataValue(NodeMetadata.blockType) == blockquoteAttribution); diff --git a/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart b/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart index 763f1277..4e3e4e61 100644 --- a/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart +++ b/wonky_write/lib/features/editor/reactions/block_conversion_reactions.dart @@ -54,6 +54,9 @@ class BlockPrefixConversionReaction extends EditReaction { final isParagraph = node is ParagraphNode && node.getMetadataValue(NodeMetadata.blockType) == paragraphAttribution; + final isQuote = + node is ParagraphNode && + node.getMetadataValue(NodeMetadata.blockType) == blockquoteAttribution; final requests = []; final rest = node.text.copyText(prefix.length); @@ -85,29 +88,49 @@ class BlockPrefixConversionReaction extends EditReaction { ), ); } - } else if (_bullet.hasMatch(prefix) && isParagraph) { - requests.add( - ConvertParagraphToListItemRequest( - nodeId: node.id, - type: ListItemType.unordered, - ), - ); - } else if (_ordered.hasMatch(prefix) && isParagraph) { + } else if ((_bullet.hasMatch(prefix) || _ordered.hasMatch(prefix)) && + (isParagraph || isQuote)) { + final type = _bullet.hasMatch(prefix) + ? ListItemType.unordered + : ListItemType.ordered; + if (isParagraph) { + requests.add( + ConvertParagraphToListItemRequest(nodeId: node.id, type: type), + ); + } else { + // In a quote line: a list item inside the quote (§14). + deletePrefix = false; + requests.add( + ReplaceNodeRequest( + existingNodeId: node.id, + newNode: ListItemNode( + id: node.id, + itemType: type, + text: rest, + metadata: _carriedListMetadata(node), + ), + ), + ); + } + } else if (_quote.hasMatch(prefix) && isParagraph) { requests.add( - ConvertParagraphToListItemRequest( + ChangeParagraphBlockTypeRequest( nodeId: node.id, - type: ListItemType.ordered, + blockType: blockquoteAttribution, ), ); - } else if (_quote.hasMatch(prefix) && isParagraph) { + } else if (_quote.hasMatch(prefix) && + (node is ListItemNode || node is TaskNode) && + !isQuotedListNode(node)) { + // `> ` at the start of a list item: the item moves into a quote. requests.add( - ChangeParagraphBlockTypeRequest( + ChangeNodeMetadataRequest( nodeId: node.id, - blockType: blockquoteAttribution, + metadata: {WonkyNodeMetadata.quoted: true}, ), ); } else if ((match = _task.firstMatch(prefix)) != null && - (isParagraph || node is ListItemNode)) { + (isParagraph || isQuote || node is ListItemNode)) { final checked = (match!.group(2) ?? ' ').toLowerCase() == 'x'; deletePrefix = false; requests.add( @@ -118,7 +141,7 @@ class BlockPrefixConversionReaction extends EditReaction { text: rest, isComplete: checked, indent: node is ListItemNode ? node.indent : 0, - metadata: _carriedMetadata(node), + metadata: _carriedListMetadata(node), ), ), ); @@ -186,4 +209,11 @@ class BlockPrefixConversionReaction extends EditReaction { ); return {WonkyNodeMetadata.lineAttributes: ?lineAttributes}; } + + /// [_carriedMetadata] of a node becoming a list or task item; it stays in + /// the quote the node is in. + static Map _carriedListMetadata(DocumentNode node) => { + ..._carriedMetadata(node), + if (isInQuoteNode(node)) WonkyNodeMetadata.quoted: true, + }; } diff --git a/wonky_write/lib/features/editor/requests/block_editing_handlers.dart b/wonky_write/lib/features/editor/requests/block_editing_handlers.dart index e2320dd9..6751b528 100644 --- a/wonky_write/lib/features/editor/requests/block_editing_handlers.dart +++ b/wonky_write/lib/features/editor/requests/block_editing_handlers.dart @@ -117,6 +117,17 @@ EditCommand? _newlineCommand( return null; } + // A list or task item inside a quote: new items stay in the quote; Enter + // on an empty top-level item leaves the list but not the quote (§14). + if (isQuotedListNode(node)) { + final indent = _indentOf(node); + if (text.isEmpty) { + return indent > 0 + ? _SetIndentCommand(node.id, indent - 1) + : _ToParagraphCommand(node.id, quote: true); + } + return _SplitQuotedItemCommand(node.id, offset, newNodeId); + } if (node is ListItemNode && text.isEmpty && node.indent > 0) { return _SetIndentCommand(node.id, node.indent - 1); } @@ -186,19 +197,21 @@ EditCommand? _backspaceAtStartCommand(Editor editor, TextNode node) { ? _MergeTextNodesCommand(previous.id, node.id) : null; } - if (node is ListItemNode) { - return node.indent > 0 - ? _SetIndentCommand(node.id, node.indent - 1) - : _ToParagraphCommand(node.id); - } - if (node is TaskNode) { - return node.indent > 0 - ? _SetIndentCommand(node.id, node.indent - 1) - : _ToParagraphCommand(node.id); + if (node is ListItemNode || node is TaskNode) { + // Inside a quote, a top-level item becomes a quote line (§14). + final indent = _indentOf(node); + return indent > 0 + ? _SetIndentCommand(node.id, indent - 1) + : _ToParagraphCommand(node.id, quote: isQuotedListNode(node)); } return null; } +int _indentOf(DocumentNode node) => switch (node) { + ListItemNode(:final indent) || TaskNode(:final indent) => indent, + _ => 0, +}; + EditCommand? _deleteCommand(Editor editor) { final selection = editor.composer.selection; if (selection == null || !selection.isCollapsed) return null; @@ -423,6 +436,54 @@ class _SplitKeepingTypeCommand extends EditCommand { } } +/// Splits a list or task item inside a quote: the new item has the same type +/// and indent and stays in the quote (a new task is unchecked). +class _SplitQuotedItemCommand extends EditCommand { + _SplitQuotedItemCommand(this.nodeId, this.offset, this.newNodeId); + + final String nodeId; + final int offset; + final String newNodeId; + + @override + void execute(EditContext context, CommandExecutor executor) { + final node = context.document.getNodeById(nodeId)! as TextNode; + final rest = node.text.copyText(offset); + final metadata = {WonkyNodeMetadata.quoted: true}; + final (TextNode first, TextNode second) = switch (node) { + ListItemNode() => ( + node.copyListItemWith(text: node.text.copyText(0, offset)), + ListItemNode( + id: newNodeId, + itemType: node.type, + indent: node.indent, + text: rest, + metadata: metadata, + ), + ), + TaskNode() => ( + node.copyTaskWith(text: node.text.copyText(0, offset)), + TaskNode( + id: newNodeId, + text: rest, + isComplete: false, + indent: node.indent, + metadata: metadata, + ), + ), + _ => throw StateError('Not a list item: $nodeId'), + }; + executor + ..executeCommand( + ReplaceNodeCommand(existingNodeId: nodeId, newNode: first), + ) + ..executeCommand( + InsertNodeAfterNodeCommand(existingNodeId: nodeId, newNode: second), + ); + _placeCaret(executor, newNodeId, const TextNodePosition(offset: 0)); + } +} + /// Enter at the start of a non-empty heading: an empty Normal paragraph above. class _ParagraphAboveCommand extends EditCommand { _ParagraphAboveCommand(this.nodeId, this.newNodeId); @@ -494,11 +555,13 @@ class _SetIndentCommand extends EditCommand { } } -/// Converts a list item or task to a Normal paragraph (keeps line overlays). +/// Converts a list item or task to a Normal paragraph, or with [quote] to a +/// quote line (keeps line overlays). class _ToParagraphCommand extends EditCommand { - _ToParagraphCommand(this.nodeId); + _ToParagraphCommand(this.nodeId, {this.quote = false}); final String nodeId; + final bool quote; @override void execute(EditContext context, CommandExecutor executor) { @@ -512,7 +575,10 @@ class _ToParagraphCommand extends EditCommand { newNode: ParagraphNode( id: nodeId, text: node.text, - metadata: {WonkyNodeMetadata.lineAttributes: ?lineAttributes}, + metadata: { + WonkyNodeMetadata.lineAttributes: ?lineAttributes, + if (quote) NodeMetadata.blockType: blockquoteAttribution, + }, ), ), ); diff --git a/wonky_write/lib/features/editor/schema_guard/schema_guard_reaction.dart b/wonky_write/lib/features/editor/schema_guard/schema_guard_reaction.dart index ea638c1e..4ca7b080 100644 --- a/wonky_write/lib/features/editor/schema_guard/schema_guard_reaction.dart +++ b/wonky_write/lib/features/editor/schema_guard/schema_guard_reaction.dart @@ -182,12 +182,16 @@ class WonkySchemaGuardReaction extends EditReaction { } } - // List indentation: at most 8, at most one deeper than the previous item. + // List indentation: at most 8, at most one deeper than the previous item + // of the same list (a quoted and an unquoted list are two lists). if (result is ListItemNode || result is TaskNode) { final indent = result is ListItemNode ? result.indent : (result as TaskNode).indent; - final previousIndent = previous is ListItemNode + final sameList = isQuotedListNode(previous) == isQuotedListNode(result); + final previousIndent = !sameList + ? -1 + : previous is ListItemNode ? previous.indent : previous is TaskNode ? previous.indent diff --git a/wonky_write/lib/features/editor/styles/write_stylesheet.dart b/wonky_write/lib/features/editor/styles/write_stylesheet.dart index 699f7e1e..045c5239 100644 --- a/wonky_write/lib/features/editor/styles/write_stylesheet.dart +++ b/wonky_write/lib/features/editor/styles/write_stylesheet.dart @@ -127,6 +127,19 @@ class WriteStylesheetBuilder { : style.letterSpacing! * scale, ); double px(double value) => value * scale; + // List and task items; inside a quote they sit in the quote's indent + // and their spacing is inside the bar (WonkyParagraphComponentBuilder). + CascadingPadding listItemPadding(DocumentNode node) => + isQuotedListNode(node) + ? CascadingPadding.only( + left: px(theme.quoteBarWidth + 12), + top: 0, + bottom: 0, + ) + : CascadingPadding.only( + top: px(theme.listItemSpacing), + bottom: px(theme.listItemSpacing), + ); final maxWidth = layout == LayoutMode.pageless ? px(theme.pagelessMaxWidth) : (contentWidth ?? 794) * scale; @@ -163,10 +176,13 @@ class WriteStylesheetBuilder { StyleRule(const BlockSelector('blockquote'), (doc, node) { return { Styles.textStyle: scaled(theme.quote), + // The space above and below each quote line is inside the bar + // (WonkyParagraphComponentBuilder), so consecutive lines and + // paragraphs of one quote share one unbroken bar (Write 04 §14). Styles.padding: CascadingPadding.only( left: px(theme.quoteBarWidth + 12), - top: px(2), - bottom: px(2), + top: 0, + bottom: 0, ), }; }), @@ -182,10 +198,8 @@ class WriteStylesheetBuilder { final indent = node is ListItemNode ? node.indent : 0; final glyph = theme.bullets[indent % theme.bullets.length]; return { - Styles.padding: CascadingPadding.only( - top: px(theme.listItemSpacing), - bottom: px(theme.listItemSpacing), - ), + Styles.padding: listItemPadding(node), + if (isQuotedListNode(node)) Styles.textStyle: scaled(theme.quote), Styles.dotColor: theme.listMarkerColor, Styles.dotShape: glyph == '▪' ? BoxShape.rectangle : BoxShape.circle, Styles.dotSize: Size( @@ -201,10 +215,8 @@ class WriteStylesheetBuilder { }), StyleRule(const BlockSelector('task'), (doc, node) { return { - Styles.padding: CascadingPadding.only( - top: px(theme.listItemSpacing), - bottom: px(theme.listItemSpacing), - ), + Styles.padding: listItemPadding(node), + if (isQuotedListNode(node)) Styles.textStyle: scaled(theme.quote), }; }), StyleRule(const BlockSelector('horizontalRule'), (doc, node) { diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index 8e5c148a..6d2578dd 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -925,6 +925,14 @@ class WonkyEditorState extends State { controller.editor, editable: editable, onToggle: (nodeId) => _commands.toggleChecked(nodeId: nodeId), + theme: theme, + zoom: zoom, + document: document, + ), + WonkyListItemComponentBuilder( + document: document ?? controller.document, + theme: theme, + zoom: zoom, ), WonkyParagraphComponentBuilder( document: document ?? controller.document, diff --git a/wonky_write/lib/features/publish/submit_dialog.dart b/wonky_write/lib/features/publish/submit_dialog.dart index 2418f0be..a07ec540 100644 --- a/wonky_write/lib/features/publish/submit_dialog.dart +++ b/wonky_write/lib/features/publish/submit_dialog.dart @@ -1,43 +1,50 @@ import 'package:flutter/widgets.dart'; import 'package:wonky_app/wonky_app.dart'; import 'package:wonky_client/wonky_client.dart'; +import 'package:wonky_core/wonky_core.dart' show UuidV7Generator; import '../../l10n/write_l10n.dart'; +import '../images/image_dialogs.dart'; +import '../images/image_services.dart'; import '../screen/write_document_controller.dart'; /// The default name of a story's document (server: `PublicationService`). const untitledStoryName = 'Untitled story'; -/// An image of the document that can be the cover (specs/18 §4.2). -class StoryImage { - const StoryImage(this.fileUuid, this.alt); +/// Picks the cover image instead of the platform picker (tests). +@visibleForTesting +Future Function()? debugPickCoverImage; - final UuidValue fileUuid; - final String alt; -} - -/// The uploaded images of the document, in order. -List storyImages(WriteDocumentController doc) { - final out = []; - final seen = {}; - for (final op in doc.editor.delta.toList()) { - final data = op.data; - if (data is! Map || data['image'] is! Map) continue; - final image = data['image'] as Map; - final src = '${image['src'] ?? ''}'; - const scheme = 'wonky-file://'; - if (!src.startsWith(scheme)) continue; - final id = src.substring(scheme.length); - if (!seen.add(id)) continue; - try { - out.add( - StoryImage(UuidValue.withValidation(id), '${image['alt'] ?? ''}'), - ); - } on FormatException { - continue; - } - } - return out; +/// Picks an image and uploads it as the story's cover (specs/18 §4.2): an +/// image file of the document (counted like its images, readable by the +/// moderators) that is not inserted into the text, so blogs that show the +/// cover above the post don't show it twice. Null when cancelled. +Future uploadCoverImage( + ShellServices s, + WriteDocumentController doc, +) async { + final l = writeL10nOf(s); + final picked = debugPickCoverImage != null + ? await debugPickCoverImage!() + : (await pickImageFiles( + multiple: false, + onRejected: (name, reason) => + s.toasts.error(reason, description: name), + l: l, + )).firstOrNull; + if (picked == null) return null; + if (!await imagesFitStorage(s, [picked])) return null; + final fileUuid = UuidValue.withValidation( + UuidV7Generator.instance.nextString(), + ); + await s.repositories.files.upload( + fileUuid: fileUuid, + itemUuid: doc.itemUuid, + fileName: picked.name, + mimeType: picked.mimeType, + bytes: picked.bytes, + ); + return (value: fileUuid.uuid, preview: MemoryImage(picked.bytes)); } /// The number of open suggestions in the document (they are sent as the @@ -78,14 +85,11 @@ Future showSubmitDialog( if (v == null) return null; final l = writeL10n(context); final f = v.fields; - final images = storyImages(doc); final suggestions = openSuggestionCount(doc); String? need(PublicationFieldMode mode, String value) => mode == PublicationFieldMode.required && value.trim().isEmpty ? l.submitRequired : null; - final cover = - v.values.coverImage ?? (images.isEmpty ? null : images.first.fileUuid); final r = await showWonkyFormDialog( context, WonkyDialogSpec( @@ -127,17 +131,22 @@ Future showSubmitDialog( validator: (x) => need(f.tags, x), ), if (f.coverImage != PublicationFieldMode.off) - SelectFieldSpec( + ImageFieldSpec( id: 'cover', label: _label(l, l.submitFieldCover, f.coverImage), - options: { - '': l.submitNoCover, - for (final (i, img) in images.indexed) - img.fileUuid.uuid: img.alt.isEmpty - ? l.submitImage(i + 1) - : l.submitImageAlt(i + 1, img.alt), - }, - initial: cover?.uuid ?? '', + // The stored cover (also one picked from the document's images + // before covers were uploaded); none on a first submit. + initial: v.values.coverImage?.uuid ?? '', + preview: (id) => + doc.images.resolve('${WriteImageServices.scheme}$id'), + pick: () => uploadCoverImage(s, doc), + description: l.submitCoverHint, + uploadLabel: l.submitCoverUpload, + replaceLabel: l.submitCoverReplace, + removeLabel: l.submitCoverRemove, + emptyLabel: l.submitNoCover, + uploadingLabel: l.submitCoverUploading, + errorLabel: l.submitCoverFailed, ), if (f.authorBio != PublicationFieldMode.off) TextFieldSpec( diff --git a/wonky_write/lib/l10n/gen/write_localizations.dart b/wonky_write/lib/l10n/gen/write_localizations.dart index c7a81ff7..ad82c122 100644 --- a/wonky_write/lib/l10n/gen/write_localizations.dart +++ b/wonky_write/lib/l10n/gen/write_localizations.dart @@ -8355,23 +8355,47 @@ abstract class WriteLocalizations { /// **'Comma separated, from: {tags}'** String submitTagsFrom(String tags); - /// No description provided for @submitNoCover. + /// Submit dialog: shown in the empty cover image thumbnail. /// /// In en, this message translates to: - /// **'No cover image'** + /// **'No cover'** String get submitNoCover; - /// No description provided for @submitImage. + /// Submit dialog: picks an image file and uploads it as the story's cover (specs/18 §4.2). /// /// In en, this message translates to: - /// **'Image {count}'** - String submitImage(int count); + /// **'Upload image'** + String get submitCoverUpload; - /// No description provided for @submitImageAlt. + /// Submit dialog: uploads another image in place of the cover. /// /// In en, this message translates to: - /// **'Image {count}: {alt}'** - String submitImageAlt(int count, String alt); + /// **'Replace'** + String get submitCoverReplace; + + /// Submit dialog: submits without a cover image. + /// + /// In en, this message translates to: + /// **'Remove'** + String get submitCoverRemove; + + /// No description provided for @submitCoverUploading. + /// + /// In en, this message translates to: + /// **'Uploading…'** + String get submitCoverUploading; + + /// No description provided for @submitCoverFailed. + /// + /// In en, this message translates to: + /// **'The image couldn\'t be uploaded. Try again.'** + String get submitCoverFailed; + + /// No description provided for @submitCoverHint. + /// + /// In en, this message translates to: + /// **'Shown with your post. It isn\'t added to the document.'** + String get submitCoverHint; /// No description provided for @submitTerms. /// diff --git a/wonky_write/lib/l10n/gen/write_localizations_en.dart b/wonky_write/lib/l10n/gen/write_localizations_en.dart index 3c2c1caa..63781c30 100644 --- a/wonky_write/lib/l10n/gen/write_localizations_en.dart +++ b/wonky_write/lib/l10n/gen/write_localizations_en.dart @@ -4741,17 +4741,26 @@ class WriteLocalizationsEn extends WriteLocalizations { } @override - String get submitNoCover => 'No cover image'; + String get submitNoCover => 'No cover'; @override - String submitImage(int count) { - return 'Image $count'; - } + String get submitCoverUpload => 'Upload image'; @override - String submitImageAlt(int count, String alt) { - return 'Image $count: $alt'; - } + String get submitCoverReplace => 'Replace'; + + @override + String get submitCoverRemove => 'Remove'; + + @override + String get submitCoverUploading => 'Uploading…'; + + @override + String get submitCoverFailed => 'The image couldn\'t be uploaded. Try again.'; + + @override + String get submitCoverHint => + 'Shown with your post. It isn\'t added to the document.'; @override String submitTerms(String publication, String url) { @@ -9635,17 +9644,27 @@ class WriteLocalizationsEnXa extends WriteLocalizationsEn { } @override - String get submitNoCover => '[Ñö çöṽéŕ îɱåĝé one t]'; + String get submitNoCover => '[Ñö çöṽéŕ one]'; @override - String submitImage(int count) { - return '[Îɱåĝé $count on]'; - } + String get submitCoverUpload => '[Ûþļöåð îɱåĝé one]'; @override - String submitImageAlt(int count, String alt) { - return '[Îɱåĝé $count: $alt one]'; - } + String get submitCoverReplace => '[Ŕéþļåçé one]'; + + @override + String get submitCoverRemove => '[Ŕéɱöṽé one]'; + + @override + String get submitCoverUploading => '[Ûþļöåðîñĝ… one]'; + + @override + String get submitCoverFailed => + '[Ţĥé îɱåĝé çöûļðñ\'ţ ƀé ûþļöåðéð. Ţŕý åĝåîñ. one two three]'; + + @override + String get submitCoverHint => + '[Šĥöŵñ ŵîţĥ ýöûŕ þöšţ. Îţ îšñ\'ţ åððéð ţö ţĥé ðöçûɱéñţ. one two three fo]'; @override String submitTerms(String publication, String url) { diff --git a/wonky_write/lib/l10n/write_en.arb b/wonky_write/lib/l10n/write_en.arb index 6ff272f5..553fbd00 100644 --- a/wonky_write/lib/l10n/write_en.arb +++ b/wonky_write/lib/l10n/write_en.arb @@ -3298,11 +3298,17 @@ "submitTagsHint": "Comma separated", "submitTagsFrom": "Comma separated, from: {tags}", "@submitTagsFrom": {"placeholders": {"tags": {"type": "String"}}}, - "submitNoCover": "No cover image", - "submitImage": "Image {count}", - "@submitImage": {"placeholders": {"count": {"type": "int"}}}, - "submitImageAlt": "Image {count}: {alt}", - "@submitImageAlt": {"placeholders": {"count": {"type": "int"}, "alt": {"type": "String"}}}, + "submitNoCover": "No cover", + "@submitNoCover": {"description": "Submit dialog: shown in the empty cover image thumbnail."}, + "submitCoverUpload": "Upload image", + "@submitCoverUpload": {"description": "Submit dialog: picks an image file and uploads it as the story's cover (specs/18 §4.2)."}, + "submitCoverReplace": "Replace", + "@submitCoverReplace": {"description": "Submit dialog: uploads another image in place of the cover."}, + "submitCoverRemove": "Remove", + "@submitCoverRemove": {"description": "Submit dialog: submits without a cover image."}, + "submitCoverUploading": "Uploading…", + "submitCoverFailed": "The image couldn't be uploaded. Try again.", + "submitCoverHint": "Shown with your post. It isn't added to the document.", "submitTerms": "I agree to {publication}'s contributor terms ({url})", "@submitTerms": {"placeholders": {"publication": {"type": "String"}, "url": {"type": "String"}}}, "submitTermsRequired": "Agree to {publication}'s contributor terms to submit.", diff --git a/wonky_write/lib/l10n/write_en_XA.arb b/wonky_write/lib/l10n/write_en_XA.arb index f50c8eb6..7b3e1a1f 100644 --- a/wonky_write/lib/l10n/write_en_XA.arb +++ b/wonky_write/lib/l10n/write_en_XA.arb @@ -1376,9 +1376,13 @@ "submitRequired": "[Ŕéǫûîŕéð. one]", "submitTagsHint": "[Çöɱɱå šéþåŕåţéð one t]", "submitTagsFrom": "[Çöɱɱå šéþåŕåţéð, ƒŕöɱ: {tags} one two]", - "submitNoCover": "[Ñö çöṽéŕ îɱåĝé one t]", - "submitImage": "[Îɱåĝé {count} on]", - "submitImageAlt": "[Îɱåĝé {count}: {alt} one]", + "submitNoCover": "[Ñö çöṽéŕ one]", + "submitCoverUpload": "[Ûþļöåð îɱåĝé one]", + "submitCoverReplace": "[Ŕéþļåçé one]", + "submitCoverRemove": "[Ŕéɱöṽé one]", + "submitCoverUploading": "[Ûþļöåðîñĝ… one]", + "submitCoverFailed": "[Ţĥé îɱåĝé çöûļðñ'ţ ƀé ûþļöåðéð. Ţŕý åĝåîñ. one two three]", + "submitCoverHint": "[Šĥöŵñ ŵîţĥ ýöûŕ þöšţ. Îţ îšñ'ţ åððéð ţö ţĥé ðöçûɱéñţ. one two three fo]", "submitTerms": "[Î åĝŕéé ţö {publication}'š çöñţŕîƀûţöŕ ţéŕɱš ({url}) one two th]", "submitTermsRequired": "[Åĝŕéé ţö {publication}'š çöñţŕîƀûţöŕ ţéŕɱš ţö šûƀɱîţ. one two thre]", "submitCoverRequired": "[Þîçķ å çöṽéŕ îɱåĝé. one tw]", diff --git a/wonky_write/test/features/document/support/session_harness.dart b/wonky_write/test/features/document/support/session_harness.dart index 01992cf5..a94a32b0 100644 --- a/wonky_write/test/features/document/support/session_harness.dart +++ b/wonky_write/test/features/document/support/session_harness.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart' show pumpEventQueue; @@ -78,7 +79,38 @@ class _FakePresence extends _Unimplemented implements PresenceRepository {} class _FakeVersions extends _Unimplemented implements VersionRepository {} -class _FakeFiles extends _Unimplemented implements FileRepository {} +/// In-memory uploads (`FileRepository`): every upload is ready at once. +class FakeFiles implements FileRepository { + /// The uploads, in order. + final uploads = <({UuidValue fileUuid, UuidValue? itemUuid, String name})>[]; + + FileUrlView _view(UuidValue fileUuid, String name, String mime, int size) => + FileUrlView( + fileUuid: fileUuid, + status: StoredFileStatus.ready, + fileName: name, + mimeType: mime, + sizeBytes: size, + ); + + @override + Future upload({ + required UuidValue fileUuid, + UuidValue? itemUuid, + required String fileName, + required String mimeType, + required Uint8List bytes, + StoredFileKind kind = StoredFileKind.image, + }) async { + uploads.add((fileUuid: fileUuid, itemUuid: itemUuid, name: fileName)); + return _view(fileUuid, fileName, mimeType, bytes.length); + } + + /// Ready without a URL: images show their placeholder. + @override + Future getUrl(UuidValue fileUuid) async => + _view(fileUuid, 'image.png', 'image/png', 1); +} class _FakeMeta extends _Unimplemented implements MetaRepository {} @@ -239,7 +271,7 @@ class SessionHarness { notifications: _FakeNotifications(), presence: _FakePresence(), versions: _FakeVersions(), - files: _FakeFiles(), + files: files, meta: _FakeMeta(), publications: publications, ), @@ -265,6 +297,9 @@ class SessionHarness { /// Publications and submissions (specs/18). final FakePublicationRepository publications = FakePublicationRepository(); + + /// Uploads (images, the cover image of a submission). + final FakeFiles files = FakeFiles(); final FakeComments comments; late final ShellServices services; late final FakeWriteApi api; diff --git a/wonky_write/test/features/editor/components/blockquote_test.dart b/wonky_write/test/features/editor/components/blockquote_test.dart new file mode 100644 index 00000000..c1a027d0 --- /dev/null +++ b/wonky_write/test/features/editor/components/blockquote_test.dart @@ -0,0 +1,564 @@ +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:wonky_write/features/editor/commands/write_editor_commands.dart'; +import 'package:wonky_write/features/editor/controller/editor_types.dart'; +import 'package:wonky_write/features/editor/ui/wonky_editor.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' show MarkdownCodec; + +import '../support/editor_harness.dart'; + +/// Touch (Android) and mouse (desktop, like the web report) interactors. +final _platforms = TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.windows, +}); + +Delta _doc(List<(String, Map?)> lines) { + final delta = Delta(); + for (final (text, attributes) in lines) { + delta + ..insert(text) + ..insert('\n', attributes); + } + return delta; +} + +const _quote = {'blockquote': true}; + +Future _pump( + WidgetTester tester, + Delta delta, { + Brightness brightness = Brightness.light, +}) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final h = EditorHarness(delta); + addTearDown(h.dispose); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: brightness), + home: Scaffold( + body: WonkyEditor(controller: h.controller, documentTitle: 'Test doc'), + ), + ), + ); + await tester.pumpAndSettle(); + return h; +} + +String _nodeId(EditorHarness h, int index) => + h.controller.document.toList()[index].id; + +DocumentSelection? _selection(EditorHarness h) => h.editor.composer.selection; + +/// Lets the tap recognizer time out so the next tap is not a double tap. +Future _settleTaps(WidgetTester tester) async { + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); +} + +/// Redoes [change] like the redo handler: applies it again locally. +void _redo(EditorHarness h, LocalDeltaChange change) => h.controller.applyLocal( + change.delta, + label: 'Redo', + source: LocalChangeSource.undo, + selectionAfter: change.selectionAfter, +); + +Future _pressModB(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); +} + +/// The painted areas of the quote lines (their bars), top to bottom. +List _quoteBars(WidgetTester tester) => + tester + .widgetList( + find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == 'quote', + ), + ) + .map( + (s) => tester.getRect( + find + .descendant( + of: find.byWidget(s), + matching: find.byType(CustomPaint), + ) + .first, + ), + ) + .toList() + ..sort((a, b) => a.top.compareTo(b.top)); + +void main() { + quotedListTests(); + + testWidgets('Given a quote of three Markdown paragraphs then their bars ' + 'touch: one unbroken bar', (tester) async { + final delta = const MarkdownCodec() + .importMarkdown('Intro\n\n> First\n>\n> Second\n>\n> Third\n\nOutro\n') + .delta; + expect( + [for (final op in delta.toList()) op.attributes?['blockquote']], + [null, true, null, true, null, true, null], + reason: 'three consecutive quote lines', + ); + await _pump(tester, delta); + + final bars = _quoteBars(tester); + + expect(bars, hasLength(3)); + for (var i = 1; i < bars.length; i++) { + expect(bars[i].top, moreOrLessEquals(bars[i - 1].bottom)); + } + }); + + group('Given a single-line quote', () { + final delta = _doc([('Intro', null), ('Quoted line', _quote)]); + + for (final brightness in Brightness.values) { + testWidgets( + 'when tapping its text (${brightness.name}) then the caret goes there', + (tester) async { + final h = await _pump(tester, delta, brightness: brightness); + final quoteId = _nodeId(h, 1); + + await tester.placeCaretInParagraph(quoteId, 3); + + expect(_selection(h)?.extent.nodeId, quoteId); + expect( + _selection(h)?.extent.nodePosition, + const TextNodePosition(offset: 3), + ); + }, + variant: _platforms, + ); + } + + testWidgets( + 'when typing in it then the text stays in the quote, and undo and redo ' + 'work', + (tester) async { + final h = await _pump(tester, delta); + await tester.placeCaretInParagraph(_nodeId(h, 1), 6); + + await tester.typeImeText(' new'); + await tester.pump(); + + final typed = _doc([('Intro', null), ('Quoted new line', _quote)]); + expect(h.delta, typed); + // One change per keystroke (the undo stack coalesces them). + final typing = h.changes.toList(); + for (final change in typing.reversed) { + h.undo(change); + } + expect(h.delta, delta); + for (final change in typing) { + _redo(h, change); + } + expect(h.delta, typed); + }, + variant: _platforms, + ); + + testWidgets( + 'when double-tapping a word and pressing Mod+B then the word is bold ' + 'and undo removes it', + (tester) async { + final h = await _pump(tester, delta); + final quoteId = _nodeId(h, 1); + + await tester.doubleTapInParagraph(quoteId, 8); + expect( + _selection(h)?.normalize(h.controller.document), + DocumentSelection( + base: DocumentPosition( + nodeId: quoteId, + nodePosition: const TextNodePosition(offset: 7), + ), + extent: DocumentPosition( + nodeId: quoteId, + nodePosition: const TextNodePosition(offset: 11), + ), + ), + ); + await _pressModB(tester); + + expect( + h.delta, + Delta() + ..insert('Intro\n') + ..insert('Quoted ') + ..insert('line', {'bold': true}) + ..insert('\n', _quote), + ); + h.undo(h.changes.last); + expect(h.delta, delta); + }, + variant: _platforms, + ); + }); + + group('Given a three-line quote', () { + final delta = _doc([ + ('Intro', null), + ('First line', _quote), + ('Second line', _quote), + ('Third line', _quote), + ('Outro', null), + ]); + + testWidgets('when tapping each line then the caret goes into that line', ( + tester, + ) async { + final h = await _pump(tester, delta); + + for (final index in [3, 1, 2]) { + await tester.placeCaretInParagraph(_nodeId(h, index), 2); + await _settleTaps(tester); + + expect(_selection(h)?.extent.nodeId, _nodeId(h, index)); + } + }, variant: _platforms); + + testWidgets( + 'when typing in the middle line and pressing Enter then a new quote line ' + 'is added, and undo restores the quote', + (tester) async { + final h = await _pump(tester, delta); + await tester.placeCaretInParagraph(_nodeId(h, 2), 11); + + await tester.typeImeText('!'); + await tester.pressEnter(); + await tester.typeImeText('More'); + await tester.pump(); + + expect( + h.delta, + _doc([ + ('Intro', null), + ('First line', _quote), + ('Second line!', _quote), + ('More', _quote), + ('Third line', _quote), + ('Outro', null), + ]), + ); + for (final change in h.changes.reversed.toList()) { + h.undo(change); + } + expect(h.delta, delta); + }, + variant: _platforms, + ); + + testWidgets( + 'when selecting across the lines and pressing Mod+B then all of them ' + 'are bold', + (tester) async { + final h = await _pump(tester, delta); + await tester.placeCaretInParagraph(_nodeId(h, 1), 0); + // "Intro\n" is 6 characters; select from "First" to the end of + // "Third line". + h.select(6, 6 + 'First line\nSecond line\nThird line'.length); + await tester.pump(); + + await _pressModB(tester); + + expect( + h.delta, + Delta() + ..insert('Intro\n') + ..insert('First line', {'bold': true}) + ..insert('\n', _quote) + ..insert('Second line', {'bold': true}) + ..insert('\n', _quote) + ..insert('Third line', {'bold': true}) + ..insert('\n', _quote) + ..insert('Outro\n'), + ); + }, + variant: _platforms, + ); + }); + + // An unquoted list item between two quotes (two quotes, Write 04 §14). + group('Given a list between quote lines', () { + final delta = _doc([ + ('Quote one', _quote), + ('Item', {'list': 'bullet'}), + ('Quote two', _quote), + ]); + + testWidgets( + 'when tapping and typing in the item and the quote after it then both ' + 'are edited, and undo restores them', + (tester) async { + final h = await _pump(tester, delta); + + await tester.placeCaretInParagraph(_nodeId(h, 1), 4); + expect(_selection(h)?.extent.nodeId, _nodeId(h, 1)); + await tester.typeImeText('s'); + await _settleTaps(tester); + await tester.placeCaretInParagraph(_nodeId(h, 2), 9); + expect(_selection(h)?.extent.nodeId, _nodeId(h, 2)); + await tester.typeImeText('!'); + await tester.pump(); + + expect( + h.delta, + _doc([ + ('Quote one', _quote), + ('Items', {'list': 'bullet'}), + ('Quote two!', _quote), + ]), + ); + for (final change in h.changes.reversed.toList()) { + h.undo(change); + } + expect(h.delta, delta); + }, + variant: _platforms, + ); + }); +} + +/// Lists inside quotes (Write 04 §14): list lines with `blockquote: true`. +void quotedListTests() { + const bullet = {'list': 'bullet', 'blockquote': true}; + + group('Given a quote with a list inside', () { + final delta = _doc([ + ('Intro', null), + ('Said:', _quote), + ('One', bullet), + ('Two', bullet), + ('Done', {'list': 'checked', 'blockquote': true}), + ('Outro', null), + ]); + + testWidgets('then the items are quoted list nodes under one unbroken ' + 'bar', (tester) async { + final h = await _pump(tester, delta); + final nodes = h.controller.document.toList(); + + expect(nodes[2], isA()); + expect(nodes[4], isA()); + expect( + [for (final n in nodes) n.getMetadataValue('wonkyQuoted') == true], + [false, false, true, true, true, false], + ); + final bars = _quoteBars(tester); + expect(bars, hasLength(4)); + for (var i = 1; i < bars.length; i++) { + expect(bars[i].top, moreOrLessEquals(bars[i - 1].bottom)); + } + expect(h.delta, delta, reason: 'the mapping round-trips'); + }); + + testWidgets('when tapping and typing in an item then it stays in the ' + 'quote, and undo restores it', (tester) async { + final h = await _pump(tester, delta); + + await tester.placeCaretInParagraph(_nodeId(h, 3), 3); + expect(_selection(h)?.extent.nodeId, _nodeId(h, 3)); + await tester.typeImeText('!'); + await tester.pump(); + + expect( + h.delta, + _doc([ + ('Intro', null), + ('Said:', _quote), + ('One', bullet), + ('Two!', bullet), + ('Done', {'list': 'checked', 'blockquote': true}), + ('Outro', null), + ]), + ); + for (final change in h.changes.reversed.toList()) { + h.undo(change); + } + expect(h.delta, delta); + }, variant: _platforms); + + testWidgets('when pressing Enter in an item then the new item is in the ' + 'quote; Enter on it again leaves the list but not the quote', ( + tester, + ) async { + final h = await _pump(tester, delta); + await tester.placeCaretInParagraph(_nodeId(h, 3), 3); + + await tester.pressEnter(); + await tester.typeImeText('Three'); + await tester.pressEnter(); + await tester.pressEnter(); + await tester.typeImeText('Back in the quote'); + await tester.pump(); + + expect( + h.delta, + _doc([ + ('Intro', null), + ('Said:', _quote), + ('One', bullet), + ('Two', bullet), + ('Three', bullet), + ('Back in the quote', _quote), + ('Done', {'list': 'checked', 'blockquote': true}), + ('Outro', null), + ]), + ); + for (final change in h.changes.reversed.toList()) { + h.undo(change); + } + expect(h.delta, delta); + }, variant: _platforms); + + testWidgets('when pressing Backspace at the start of an item then it ' + 'becomes a quote line', (tester) async { + final h = await _pump(tester, delta); + await tester.placeCaretInParagraph(_nodeId(h, 2), 0); + + await tester.pressBackspace(); + await tester.pump(); + + expect( + h.delta, + _doc([ + ('Intro', null), + ('Said:', _quote), + ('One', _quote), + ('Two', bullet), + ('Done', {'list': 'checked', 'blockquote': true}), + ('Outro', null), + ]), + ); + }, variant: _platforms); + + testWidgets('when pressing Tab in an item then it is indented and stays ' + 'in the quote', (tester) async { + final h = await _pump(tester, delta); + await tester.placeCaretInParagraph(_nodeId(h, 3), 0); + + await tester.pressTab(); + await tester.pump(); + + expect( + h.delta, + _doc([ + ('Intro', null), + ('Said:', _quote), + ('One', bullet), + ('Two', {...bullet, 'indent': 1}), + ('Done', {'list': 'checked', 'blockquote': true}), + ('Outro', null), + ]), + ); + }, variant: _platforms); + }); + + group('Given a quote line', () { + final delta = _doc([('Said:', _quote), ('', _quote)]); + + for (final (prefix, attributes) in [ + ('- ', bullet), + ('1. ', {'list': 'ordered', 'blockquote': true}), + ('[ ] ', {'list': 'unchecked', 'blockquote': true}), + ]) { + testWidgets('when typing "$prefix" at its start then it becomes a list ' + 'item inside the quote', (tester) async { + final h = await _pump(tester, delta); + await tester.placeCaretInParagraph(_nodeId(h, 1), 0); + + await tester.typeImeText('${prefix}item'); + await tester.pump(); + + expect(h.delta, _doc([('Said:', _quote), ('item', attributes)])); + }); + } + }); + + group('Given the block type commands', () { + late EditorHarness h; + late WriteEditorCommands commands; + setUp(() { + h = EditorHarness( + _doc([ + ('a', _quote), + ('b', {'list': 'bullet'}), + ]), + ); + commands = WriteEditorCommands(h.controller); + }); + tearDown(() => h.dispose()); + + test('when making a quote line a bulleted list then it is a list inside ' + 'the quote, and toggling the list off gives the quote line back', () { + h.select(0); + + commands.setBlockType(BlockKind.bulleted); + expect( + h.delta, + _doc([ + ('a', bullet), + ('b', {'list': 'bullet'}), + ]), + ); + expect(commands.selectionInQuote, isTrue); + commands.setBlockType(BlockKind.bulleted); + + expect( + h.delta, + _doc([ + ('a', _quote), + ('b', {'list': 'bullet'}), + ]), + ); + }); + + test('when toggling Quote on a list item then it moves into a quote, and ' + 'toggling it again takes it out (still a list item)', () { + h.select(2); + + commands.setBlockType(BlockKind.quote); + expect(h.delta, _doc([('a', _quote), ('b', bullet)])); + commands.setBlockType(BlockKind.quote); + + expect( + h.delta, + _doc([ + ('a', _quote), + ('b', {'list': 'bullet'}), + ]), + ); + }); + + test('when turning a quoted item into a heading then it leaves the ' + 'quote', () { + h.select(0); + commands.setBlockType(BlockKind.bulleted); + + commands.setBlockType(BlockKind.heading2, toggle: false); + + expect( + h.delta, + _doc([ + ('a', {'header': 2}), + ('b', {'list': 'bullet'}), + ]), + ); + }); + }); +} diff --git a/wonky_write/test/features/publish/submission_test.dart b/wonky_write/test/features/publish/submission_test.dart index 07e5a536..84cf101f 100644 --- a/wonky_write/test/features/publish/submission_test.dart +++ b/wonky_write/test/features/publish/submission_test.dart @@ -1,10 +1,16 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shad/shad.dart'; import 'package:wonky_app/testing.dart'; import 'package:wonky_client/wonky_client.dart'; import 'package:wonky_core/wonky_core.dart'; +import 'package:wonky_write/features/images/image_services.dart'; import 'package:wonky_write/features/publish/submission_commands.dart'; import 'package:wonky_write/features/publish/submission_title_actions.dart'; +import 'package:wonky_write/features/publish/submit_dialog.dart'; import '../document/support/session_harness.dart' as session; import '../screen/support/screen_harness.dart'; @@ -18,6 +24,7 @@ void main() { WidgetTester tester, { SubmissionView? view, String markdown = '# Faster CI\n\nHello\n', + Brightness brightness = Brightness.light, }) async { final h = await ScreenHarness.open(tester, markdown: markdown); final repo = h.session.publications; @@ -29,6 +36,7 @@ void main() { Center( child: SubmissionTitleActions(services: h.services, doc: h.doc), ), + brightness: brightness, ); await tester.runAsync(() => h.doc.submission.refresh()); await tester.pumpAndSettle(); @@ -91,6 +99,122 @@ void main() { }); }); + group('Given a publication that asks for a cover image', () { + /// A 1×1 PNG. + final png = Uint8List.fromList( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + ), + ); + SubmissionView coverView({ + PublicationFieldMode mode = PublicationFieldMode.required, + }) { + final v = FakePublicationRepository.view(uuid); + return v.copyWith(fields: v.fields.copyWith(coverImage: mode)); + } + + setUp( + () => + debugPickCoverImage = () async => + PickedImage(name: 'cover.png', bytes: png, mimeType: 'image/png'), + ); + tearDown(() => debugPickCoverImage = null); + + Future openDialog(WidgetTester tester) async { + await tester.tap(_key('write.submission.submit')); + await tester.pumpAndSettle(); + } + + for (final brightness in Brightness.values) { + testWidgets('then the dialog (${brightness.name}) offers to upload one, ' + 'with an empty themed thumbnail and no Remove', (tester) async { + final h = await open(tester, view: coverView(), brightness: brightness); + + await openDialog(tester); + + expect(_key('dialog.submit.cover'), findsOneWidget); + expect(_key('dialog.submit.cover.upload'), findsOneWidget); + expect(_key('dialog.submit.cover.remove'), findsNothing); + expect(find.text('Upload image'), findsOneWidget); + expect(find.text('No cover'), findsOneWidget); + final cs = ShadTheme.of( + tester.element(_key('dialog.submit.cover')), + ).colorScheme; + final box = tester.widget( + find.descendant( + of: _key('dialog.submit.cover'), + matching: find.byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration, + ), + ), + ); + expect((box.decoration! as BoxDecoration).color, cs.muted); + await h.dispose(tester); + }); + } + + testWidgets('when uploading an image and submitting then it is uploaded ' + 'to the document and sent as the cover', (tester) async { + final h = await open(tester, view: coverView()); + await openDialog(tester); + + await tester.tap(_key('dialog.submit.cover.upload')); + await tester.pumpAndSettle(); + + final upload = h.session.files.uploads.single; + expect(upload.itemUuid, uuid); + expect(upload.name, 'cover.png'); + expect(_key('dialog.submit.cover.preview'), findsOneWidget); + expect(find.text('Replace'), findsOneWidget); + expect(_key('dialog.submit.cover.remove'), findsOneWidget); + // Not inserted into the document. + expect(h.doc.editor.delta.toList().any((op) => op.data is Map), isFalse); + + await tester.tap(_key('dialog.submit.submit')); + await tester.pumpAndSettle(); + + expect(h.session.publications.lastValues!.coverImage, upload.fileUuid); + await h.dispose(tester); + }); + + testWidgets('when removing the uploaded image then the story is ' + 'submitted without a cover', (tester) async { + final h = await open( + tester, + view: coverView(mode: PublicationFieldMode.optional), + ); + await openDialog(tester); + await tester.tap(_key('dialog.submit.cover.upload')); + await tester.pumpAndSettle(); + + await tester.tap(_key('dialog.submit.cover.remove')); + await tester.pumpAndSettle(); + expect(_key('dialog.submit.cover.preview'), findsNothing); + await tester.tap(_key('dialog.submit.submit')); + await tester.pumpAndSettle(); + + expect(h.session.publications.calls, contains('submit')); + expect(h.session.publications.lastValues!.coverImage, isNull); + await h.dispose(tester); + }); + + testWidgets('when the picker is cancelled then nothing is uploaded and a ' + 'required cover blocks the submit', (tester) async { + debugPickCoverImage = () async => null; + final h = await open(tester, view: coverView()); + await openDialog(tester); + + await tester.tap(_key('dialog.submit.cover.upload')); + await tester.pumpAndSettle(); + await tester.tap(_key('dialog.submit.submit')); + await tester.pumpAndSettle(); + + expect(h.session.files.uploads, isEmpty); + expect(h.session.publications.calls, isNot(contains('submit'))); + await h.dispose(tester); + }); + }); + group('Given a story with changes requested', () { testWidgets('then the chip shows the status and the popover the message', ( tester, diff --git a/wonky_write/test/features/screen/support/screen_harness.dart b/wonky_write/test/features/screen/support/screen_harness.dart index cdbf67a0..5bc0a9d2 100644 --- a/wonky_write/test/features/screen/support/screen_harness.dart +++ b/wonky_write/test/features/screen/support/screen_harness.dart @@ -48,7 +48,11 @@ class ScreenHarness { final List invoked = []; /// Pumps [child] inside a shad app with the shell services provided. - Future pump(WidgetTester tester, Widget child) async { + Future pump( + WidgetTester tester, + Widget child, { + Brightness brightness = Brightness.light, + }) async { tester.view.physicalSize = const Size(1400, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -57,7 +61,9 @@ class ScreenHarness { Provider.value( value: services, child: ShadApp( - theme: WonkyTheme.light(), + theme: brightness == Brightness.dark + ? WonkyTheme.dark() + : WonkyTheme.light(), localizationsDelegates: const [ WonkyAppLocalizations.delegate, WonkyUiLocalizations.delegate,