diff --git a/TODO.md b/TODO.md index 6836526c..ef1f83dd 100644 --- a/TODO.md +++ b/TODO.md @@ -352,9 +352,9 @@ Everything below is deferred; the app works without it. Areas are in rough prior - **Settings split (D-12 follow-ups, suite 04 §4):** *Sign-in & security* lacks the Google account link status / *Link Google account* (needs a server call listing the user's identity providers) and *Sign out of all devices* (P2). *Offline & device data* shows only the current - app's device data, not the summary row per other app on this device. Write's *Page & export* defaults are stored but not yet - applied: new documents ignore the default layout / saved page setup and the export dialog - doesn't preselect the default format (as in v1). The `/settings/sheets` hand-over from + app's device data, not the summary row per other app on this device. Write's *Page & export* defaults are stored but not all + applied: new documents apply the default layout (T-1) but ignore the saved page setup, and the + export dialog doesn't preselect the default format (as in v1). The `/settings/sheets` hand-over from Write and the `/settings/write` redirect were checked in the browser (D-18); the *Settings of other apps* links were not clicked. - **Shared chart packages (D-3 follow-ups, SL-27 in M10, Slides 08 §2.7, §2.10):** `ChartData` diff --git a/specs/02-design-system.md b/specs/02-design-system.md index 11d8e24e..884d99b6 100644 --- a/specs/02-design-system.md +++ b/specs/02-design-system.md @@ -300,12 +300,20 @@ tests). There is no mobile editing; v2 apps are the same Flutter web builds. the frame the layout starts in, and `false` when it ends; the apps fold it into their view mode, so no edit command is enabled whatever the role and View › Mode › Editing is off. It is layout, not a change of the document: nothing is undoable or remembered, and widening the - window restores the mode the user had. Write: Viewing, no comment margin, and (T-12) 16 px - beside the text (`WriteDocumentScreen.mobileTextPadding` as `WonkyEditor.compactPadding`) - instead of the page margins — the page fills the width — or the pageless padding; the page - setup itself is unchanged. Sheets: the grid and sheet tabs (no formula bar or filter-view - bar), with (T-12) a 1 px line in the theme's `border` color between the title row and the - grid (`sheets.mobile.separator`). Slides: Slides 04 §16.1 and the presenter mode of + window restores the mode the user had. Write: Viewing, no comment margin, (T-1) always + Pageless (`WriteDocumentController.setReadOnlyView` sets the view's layout; the page setup + itself is unchanged), (T-12, T-1) 16 px beside, above and below the text + (`WriteDocumentScreen.mobileTextPadding` as `WonkyEditor.compactPadding`) instead of the + pageless padding, (T-1) a 1 px line in the theme's `border` color between the title row and + the page (`write.mobile.separator`), and (T-1) no text selection: the selections the user + makes are collapsed (`selectionGuard`, `WriteEditorController.selectable`), which a + read-only document doesn't show, so taps and swipes only scroll. Sheets: the grid and sheet + tabs (no formula bar or filter-view bar), with (T-12) a 1 px line in the theme's `border` + color between the title row and the grid (`sheets.mobile.separator`); (T-1) no cell + selection (every pointer pans the grid, taps and long presses do nothing, the selection + isn't painted) and a touch tab bar: 48 px tall with 42 px tabs in 15 px text, the all-sheets + menu (40 px) and the tabs only — no add button, tab menus (▾ or right-click), scroll arrows, + Explore or selection stats. Slides: Slides 04 §16.1 and the presenter mode of Slides 10 §1.6. - **Presenter bar** (`WonkyPresenterBar`, `wonky_ui`): the 52 px top bar of the mobile presenter mode — close (*Exit presenter mode*), title with "3 of 12", *Previous slide* / *Next slide* diff --git a/specs/wonky_sheets/03-grid-and-editing.md b/specs/wonky_sheets/03-grid-and-editing.md index df4b72d5..858bf5a9 100644 --- a/specs/wonky_sheets/03-grid-and-editing.md +++ b/specs/wonky_sheets/03-grid-and-editing.md @@ -517,7 +517,7 @@ Undo restores the previous count. |-------|----------| | Mouse wheel | Vertical: 3 × default row height × zoom per notch (platform delta respected); Shift+wheel: horizontal | | Trackpad | Pixel-precise 2D scrolling with platform momentum (`diagonalDragBehavior.free`) | -| Touch | `GridTouchTracker` (`grid/grid_touch.dart`): a tap selects like a click (double-tap edits); one finger past the touch slop scrolls, with a `ClampingScrollSimulation` fling on release; a long press (500 ms) then drag runs the grid's pointer drag (range selection, fill handle, drag-move, header resize/move, freeze bars) instead of scrolling; two fingers pinch-zoom about their midpoint (50–200 %, 5 % steps) and pan. Mouse, pen and trackpad input are unchanged | +| Touch | `GridTouchTracker` (`grid/grid_touch.dart`): a tap selects like a click (double-tap edits); one finger past the touch slop scrolls, with a `ClampingScrollSimulation` fling on release; a long press (500 ms) then drag runs the grid's pointer drag (range selection, fill handle, drag-move, header resize/move, freeze bars) instead of scrolling; two fingers pinch-zoom about their midpoint (50–200 %, 5 % steps) and pan. Mouse, pen and trackpad input are unchanged. In the read-only mobile layout (suite 02 §6.1, T-1) there is no cell selection: every pointer (a mouse in a narrow window too) pans like one finger, with the fling, taps and long presses do nothing, and the selection isn't painted (`SheetEditorController.readOnlyView`) | | Mod+wheel / pinch (`PointerScaleEvent`, trackpad pinch on web) | Zoom about pointer (§2.3) | | Pointer cancel | A `PointerCancelEvent` (the OS or browser took the pointer, a second finger during a long-press drag) aborts the drag in progress: no fill, move, resize, freeze or paint format is committed | | Scrollbars | Thin auto-hide (`WonkyScrollArea` style); dragging the vertical thumb shows a tooltip "Row 12,345" | diff --git a/specs/wonky_sheets/04-screens-menus-toolbar.md b/specs/wonky_sheets/04-screens-menus-toolbar.md index 3a414488..1d8e69a2 100644 --- a/specs/wonky_sheets/04-screens-menus-toolbar.md +++ b/specs/wonky_sheets/04-screens-menus-toolbar.md @@ -527,6 +527,13 @@ Specified in [03 §9](03-grid-and-editing.md#9-formula-bar). Hidden via View › All sheet commands are one undo step (delete restores the sheet with all rows, cells and rules by re-inserting the same ids). +**Mobile (T-1).** In the read-only mobile layout (suite 02 §6.1) the bar is 48 px tall +(`SheetTabBar.mobileHeight`) with 42 px tabs (`mobileTabHeight`) in 15 px text, and shows the +**≡** all-sheets menu (40 px) and the tabs only: no **+**, no tab menu (▾ or right-click), no +scroll arrows (the strip scrolls with a swipe), no Explore and no selection stats (there is no +cell selection, 03 §5). A tap activates a tab at once (no double-tap rename). The desktop bar is +unchanged. + --- ## 7. Selection statistics and Explore diff --git a/specs/wonky_sheets/STATUS.md b/specs/wonky_sheets/STATUS.md index c09e8da0..036f7b0f 100644 --- a/specs/wonky_sheets/STATUS.md +++ b/specs/wonky_sheets/STATUS.md @@ -149,6 +149,11 @@ menus at the pointer, `RangeInput` picking while focused (also the pivot source skeleton layer, automatic text color by fill contrast, sort undo/redo keeping the selection. Phase 4: `RangeInput` in the filter views panel, side panel restore precedence (02 §4 `LocalViewState`), row display order for sorted filter views (03 §2.3, 07 §4.4). +Mobile (suite 02 §6.1, T-12, T-1; `test/features/sheet_editor/sheet_editor_screen_test.dart`): +the read-only layout has no cell selection (every pointer pans the grid, taps and long presses +do nothing, the selection isn't painted; 03 §5) and a touch tab bar (48 px, 42 px tabs, only +the all-sheets menu and the tabs; 04 §6). The panning was checked in tests only, not on a +phone. Known gaps of the editor: no tile cache (direct painting; median 12.6–16 ms, p95 20–25 ms per frame at 1M cells in the debug test VM on a loaded machine — decided by a release profile), bundled cell fonts (families other than the UI fonts fall back), named-range dropdown in the name box, remote-edit toasts beyond "row deleted". diff --git a/specs/wonky_write/04-editor.md b/specs/wonky_write/04-editor.md index fa42f4fc..fe6f3859 100644 --- a/specs/wonky_write/04-editor.md +++ b/specs/wonky_write/04-editor.md @@ -1608,7 +1608,12 @@ takes < 100 ms on a 100-page document. document for the session (in-memory). The word being typed (caret inside or at its end) is checked once the caret leaves it. - **Rendering**: `SpellingAndGrammarStyler` style phase with `spellingErrorAttribution` (red - wavy underline in the `destructive` token). Presentation-only (§2.5). + wavy underline in the `destructive` token). Presentation-only (§2.5). When a flagged block is + removed (Backspace merging pasted empty paragraphs, a delete), its underlines are cleared in + a microtask, not from the document listener: that listener can run before `super_editor`'s + layout has seen the removal, and a style phase marked dirty then restyles the stale view + model, which still has the removed block. The layout threw ("No such position in document") + and stopped updating, so the pasted line breaks stayed visible and couldn't be deleted (#21). - **Interactions**: - Right-click a flagged word: the context-menu top section shows up to 5 suggestions (`write.spell.replace {nodeId, range, word}`, one undo step), *Ignore* @@ -1649,8 +1654,13 @@ fixes it in one undo step. layout). ### 29.2 Layout modes -`pageSetup.layout`: `pages` (default for new documents, overridable in Settings › Write) or -`pageless`. Set with `write.doc.setLayout {layout}` (View › Page layout ▸; also in Page setup). +`pageSetup.layout`: `pageless` (default for new documents, T-1; overridable in Settings › +Write › Page & export › Default layout) or `pages`. The server applies the default when it +creates a document (blank, built-in templates, the welcome document *New here? Read me first*, +Markdown and templates without a page setup; `DriveService.newDocumentPageSetup`: the locale +defaults of 07 §6.1 with the user's layout, `writeDefaults.pageless`, Pageless unless it is +`false`). Copies, imports and user templates keep their own page setup. Phones always show +Pageless (suite 02 §6.1), whatever the setting. Set with `write.doc.setLayout {layout}` (View › Page layout ▸; also in Page setup). This is a meta change, undoable (`write.meta`). The page setup model is owned by [07-import-export-print.md](07-import-export-print.md). @@ -1698,6 +1708,12 @@ Pages are **computed, never stored**. Manual page breaks are not supported (01 stylesheet is memoized so the presenter is not recreated on every keystroke. `PageChromeLayerBuilder` paints the page cards, headers, footers and page numbers in the underlay, at offsets computed by the controller. + The page cards are painted under the document, so the phase also gives the last block + bottom padding (`tailPadding`): the rest of the last page below it plus the 48 px bottom + canvas padding (05 §16). The document's scroll extent then reaches the last page's bottom + edge, also when that page isn't full (#1). It is component padding, outside the measured + box, so it never feeds back into the measure; the document padding would not do, since + `super_editor` rebuilds its layout only when a component's view model changes. 4. **Oversized blocks (v1, P0)**: the page boundary inside the block is drawn as a **seam**. The two page cards touch, there is a dashed separator line in the margin areas, and the page number label sits in the gutter. Content is never hidden. **P1**: diff --git a/specs/wonky_write/05-screens-menus-toolbar.md b/specs/wonky_write/05-screens-menus-toolbar.md index 636e70d0..36787b12 100644 --- a/specs/wonky_write/05-screens-menus-toolbar.md +++ b/specs/wonky_write/05-screens-menus-toolbar.md @@ -663,8 +663,9 @@ export* = default layout (`write.prefs.setDefaultLayout {layout}`, key `pageless page setup saved by *Page setup › Set as default* with *Reset* (`write.prefs.resetPageSetup`), default export format (`write.prefs.setExportFormat {format}`: md, html, pdf, docx, odt, txt); *Spelling* = *Ignore words in UPPERCASE* (`write.spell.toggleIgnoreUppercase`) and the personal -dictionary (remove = `write.prefs.removeDictionaryWord {word}`). New documents don't apply the -default layout/page setup yet and exports don't preselect the default format (TODO.md). +dictionary (remove = `write.prefs.removeDictionaryWord {word}`). The default layout is Pageless +when unset and new documents apply it (T-1, 04 §29.2); they don't apply the saved default page +setup yet and exports don't preselect the default format (TODO.md). ## 14. Empty, loading and error states @@ -714,7 +715,7 @@ Switching modes is instant; announcements via `SemanticsService` ("Suggesting mo | Document surface | page cards: `page` color, radius 2, light: shadow `0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.08)`; dark: 1 px `border` token, no shadow | full width | | Page size | page setup (mm/in → logical px at 96 dpi) × zoom | n/a | | Gap between pages | 16 px × zoom (min 8) | n/a | -| Canvas padding | 24 px top, 48 px bottom, horizontal centering; if viewport narrower than page: horizontal scroll unless *Fit width* | content column centered, 48 px top padding | +| Canvas padding | 24 px top, 48 px below the last page (the scroll extent always reaches it, also when the last page isn't full, 04 §29.3), horizontal centering; if viewport narrower than page: horizontal scroll unless *Fit width* | content column centered, 48 px top padding, 96 px below the last block (theme `pagelessPadding`) | | Content width | page width − margins | `min(theme pageless max width, viewport − 96)` | | Header/footer | painted in margins: theme `pageChrome` text style (default: 10 pt, `muted` equivalent of theme text), tokens expanded; double-click → Page setup › Header & footer | none | | Page number indicator | on scroll, a floating pill "Page 3 of 7" near the scrollbar thumb for 1 s | none | @@ -732,7 +733,7 @@ Breakpoints = shad `ShadBreakpoints` (sm 640, md 768, lg 1024, xl 1280, 2xl 1536 | lg–xl | full | prio ≥ 5 overflow | comment margin beside the page or overlaying the canvas's right edge | docked (squeezes canvas; Pages mode auto *Fit width* if the page no longer fits and user zoom was 100 %) | | md–lg | star/move move into a ⋮ menu; avatars max 2 | prio ≥ 3 overflow; mode switcher icon-only | comment margin overlays the right edge | docked min width or overlay if canvas < 480 px | | sm–md | title truncates; Share becomes icon button | prio ≥ 2 overflow | Pages rendered at fit-width zoom (indicator shows effective %); margin overlays (hidden below 600 px canvas) | overlay from the right over a scrim (380 px) | -| < sm (≥ 360) | app icon, title, ⋮, Share icon | Undo/Redo, style, B, I, link, list, ⋯ | Pageless-like padding 16 px (Pages still drawn as scaled cards); no margin | full-width overlay | +| < sm (≥ 360) | the read-only mobile layout (suite 02 §6.1): app icon, read-only title, sync status, *View only*, account menu, then a 1 px `border` line (`write.mobile.separator`, T-1) | none | always Pageless (T-1; the page setup is unchanged), 16 px beside, above and below the text, no text selection; no margin | none | The word count bar (§11), when on, is the same at every width. diff --git a/wonky_server/lib/src/platform/drive/drive_service.dart b/wonky_server/lib/src/platform/drive/drive_service.dart index c8b8ee80..868d428c 100644 --- a/wonky_server/lib/src/platform/drive/drive_service.dart +++ b/wonky_server/lib/src/platform/drive/drive_service.dart @@ -2,7 +2,8 @@ import 'dart:convert'; import 'package:serverpod/serverpod.dart'; import 'package:wonky_core/wonky_core.dart' show Role; -import 'package:wonky_write_core/wonky_write_core.dart' show Delta, DeltaUtils; +import 'package:wonky_write_core/wonky_write_core.dart' + show Delta, DeltaUtils, PageLayoutMode, PageSetup; import '../../sheets/spaces/space_copier.dart'; import '../../slides/decks/slides_decks.dart'; @@ -30,6 +31,7 @@ import '../sharing/acl_events.dart'; import '../storage/content_sizes.dart'; import '../storage/storage_hooks.dart'; import '../storage/storage_service.dart'; +import '../users/user_service.dart'; import 'drive_events.dart'; import 'drive_file_filters.dart'; import 'drive_listing.dart'; @@ -269,6 +271,21 @@ abstract final class DriveService { /// Creates a document. [documentUuid] and [itemUuid] may be generated by /// the client for offline creation; the call is idempotent on them. + /// The page setup JSON of a new document: the defaults for [language] + /// (`PageSetup.defaultsFor`, what the editor shows for `{}`) in the + /// user's default layout (`UserService.defaultPageless`). + static Future newDocumentPageSetup( + Session session, + Actor actor, { + String? language, + }) async { + final pageless = await UserService.defaultPageless(session, actor.userId); + final setup = PageSetup.defaultsFor(language ?? 'en').toJson() + ..['layout'] = + (pageless ? PageLayoutMode.pageless : PageLayoutMode.pages).name; + return jsonEncode(setup); + } + static Future createDocument( Session session, Actor actor, { @@ -330,6 +347,16 @@ abstract final class DriveService { } else if (initialMarkdown != null && initialMarkdown.isNotEmpty) { content = WriteContent.fromMarkdown(initialMarkdown).delta; } + // New documents (blank, built-in templates, the welcome document and + // templates without a page setup) open in the user's default layout: + // Pageless unless they chose Pages (Write 04 §29.2, T-1). + if (pageSetup == '{}') { + pageSetup = await newDocumentPageSetup( + session, + actor, + language: language, + ); + } // (v2) Quota (Drive 03 §7.2): blank documents are always allowed. if (content != null) { await StorageHooks.checkCaller( diff --git a/wonky_server/lib/src/platform/users/user_service.dart b/wonky_server/lib/src/platform/users/user_service.dart index a9bc8b3b..f5eea851 100644 --- a/wonky_server/lib/src/platform/users/user_service.dart +++ b/wonky_server/lib/src/platform/users/user_service.dart @@ -454,6 +454,17 @@ abstract final class UserService { return BuiltinThemeIds.wonkyDefault; } + /// Whether the user's new documents are Pageless (Write settings › Page & + /// export › Default layout, `writeDefaults.pageless`; Write 05 §13.3). + /// Pageless unless the user chose Pages (T-1). + static Future defaultPageless(Session session, int userId) async { + final prefs = await UserPreferences.db.findFirstRow( + session, + where: (t) => t.userId.equals(userId), + ); + return decodeObject(prefs?.writeDefaults)['pageless'] != false; + } + // --------------------------------------------------------------- search /// The users related to `@me`: the owners of and grantees on the items diff --git a/wonky_server/test/integration/platform/drive_endpoint_test.dart b/wonky_server/test/integration/platform/drive_endpoint_test.dart index 0605d4b3..88cf2299 100644 --- a/wonky_server/test/integration/platform/drive_endpoint_test.dart +++ b/wonky_server/test/integration/platform/drive_endpoint_test.dart @@ -2,10 +2,12 @@ library; import 'dart:async'; +import 'dart:convert'; import 'package:clock/clock.dart'; import 'package:serverpod/serverpod.dart'; import 'package:test/test.dart'; +import 'package:wonky_write_core/wonky_write_core.dart' show PageSetup; import 'package:wonky_server/src/platform/common/models.dart'; import 'package:wonky_server/src/platform/drive/drive_trash.dart'; import 'package:wonky_server/src/platform/drive/templates/builtin_templates.dart'; @@ -104,6 +106,67 @@ void main() { }, ); + group('when creating documents (T-1)', () { + Future> pageSetupOf(DriveItemView doc) async { + final session = sessionBuilder.build(); + final item = await DriveItem.db.findFirstRow( + session, + where: (t) => t.uuid.equals(doc.uuid), + ); + final row = await Document.db.findFirstRow( + session, + where: (t) => t.itemId.equals(item!.id), + ); + return (jsonDecode(row!.pageSetupJson) as Map).cast(); + } + + test('then a blank document is Pageless with the default page ' + 'setup', () async { + final doc = await TestDrive.document(endpoints, anna); + + final setup = await pageSetupOf(doc); + expect(setup['layout'], 'pageless'); + expect( + PageSetup.fromJson(setup), + PageSetup.fromJson( + PageSetup.defaultsFor('en').toJson()..['layout'] = 'pageless', + ), + ); + }); + + test('then documents from a built-in template or Markdown are ' + 'Pageless', () async { + final template = BuiltinTemplates.byKey('write.meeting-notes'); + final fromTemplate = await endpoints.drive.createDocument( + anna.endpointSession, + null, + 'Notes', + template.uuid, + null, + ); + final fromMarkdown = await TestDrive.document( + endpoints, + anna, + markdown: '# Hi', + ); + + expect((await pageSetupOf(fromTemplate))['layout'], 'pageless'); + expect((await pageSetupOf(fromMarkdown))['layout'], 'pageless'); + }); + + test('then a user who chose Pages as the default layout gets ' + 'Pages', () async { + await endpoints.user.updatePreferences( + anna.endpointSession, + UserPreferencesPatch(writeDefaults: jsonEncode({'pageless': false})), + ); + + final doc = await TestDrive.document(endpoints, anna); + + expect((await pageSetupOf(doc))['layout'], 'pages'); + }); + }); + test( 'when creating from a built-in template then its content is used', () async { diff --git a/wonky_server/test/integration/platform/user_endpoint_test.dart b/wonky_server/test/integration/platform/user_endpoint_test.dart index c0101e8d..5a1ad17e 100644 --- a/wonky_server/test/integration/platform/user_endpoint_test.dart +++ b/wonky_server/test/integration/platform/user_endpoint_test.dart @@ -38,6 +38,16 @@ void main() { ); expect(page.items.map((i) => i.name), ['New here? Read me first']); + // It opens Pageless, like every new document (T-1). + final item = await DriveItem.db.findFirstRow( + sessionBuilder.build(), + where: (t) => t.uuid.equals(page.items.single.uuid), + ); + final doc = await Document.db.findFirstRow( + sessionBuilder.build(), + where: (t) => t.itemId.equals(item!.id), + ); + expect(jsonDecode(doc!.pageSetupJson)['layout'], 'pageless'); }, ); 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 574c362d..2fd00a45 100644 --- a/wonky_server/test/integration/write/mcp/write_tools_test.dart +++ b/wonky_server/test/integration/write/mcp/write_tools_test.dart @@ -404,19 +404,20 @@ void main() { () async { final r = await mcp.call('write.update_page_setup', { 'document_uuid': docOf(doc), - 'patch': {'layout': 'pageless'}, + // New documents are Pageless (T-1). + 'patch': {'layout': 'pages'}, }); var setup = await mcp.call('write.get_page_setup', { 'document_uuid': docOf(doc), }); - expect((setup['page_setup'] as Map)['layout'], 'pageless'); + expect((setup['page_setup'] as Map)['layout'], 'pages'); await mcp.call('changes.revert', {'change_id': r['change_id']}); setup = await mcp.call('write.get_page_setup', { 'document_uuid': docOf(doc), }); - expect((setup['page_setup'] as Map)['layout'], isNot('pageless')); + expect((setup['page_setup'] as Map)['layout'], 'pageless'); }, ); diff --git a/wonky_sheets/lib/features/grid/grid_area.dart b/wonky_sheets/lib/features/grid/grid_area.dart index bc4a3951..b8c657fb 100644 --- a/wonky_sheets/lib/features/grid/grid_area.dart +++ b/wonky_sheets/lib/features/grid/grid_area.dart @@ -559,6 +559,17 @@ class _GridAreaState extends State with TickerProviderStateMixin { void _onDown(PointerDownEvent e) { _stopFling(); + // The read-only mobile layout (suite 02 §6.1, T-1) has no cell + // selection: every pointer (also a mouse in a narrow window) pans the + // grid like a finger, and taps and long presses do nothing. + final readOnlyView = c.readOnlyView; + _touch.longPressEnabled = !readOnlyView; + if (readOnlyView) { + if (e.kind == PointerDeviceKind.touch || e.buttons == kPrimaryButton) { + _touch.down(e); + } + return; + } if (e.kind == PointerDeviceKind.touch) { _touch.down(e); return; @@ -618,10 +629,13 @@ class _GridAreaState extends State with TickerProviderStateMixin { late final GridTouchTracker _touch = GridTouchTracker( onTap: (down, up) { + if (c.readOnlyView) return; _press(down, touch: true); _release(); }, - onLongPress: (down) => _press(down, touch: true), + onLongPress: (down) { + if (!c.readOnlyView) _press(down, touch: true); + }, onDragMove: _dragTo, onDragEnd: (_) => _release(), onDragCancel: _cancelDrag, diff --git a/wonky_sheets/lib/features/grid/grid_touch.dart b/wonky_sheets/lib/features/grid/grid_touch.dart index 4c0f7baa..35dfd0fa 100644 --- a/wonky_sheets/lib/features/grid/grid_touch.dart +++ b/wonky_sheets/lib/features/grid/grid_touch.dart @@ -30,7 +30,8 @@ enum GridTouchPhase { /// selects (as a click), one finger scrolls with momentum, a long press /// then drag runs the grid's pointer drag (range selection, fill, move, /// resize), two fingers pinch-zoom. Mouse, stylus and trackpad input don't -/// come here. +/// come here, except in the read-only mobile layout (T-1), where every +/// pointer pans and taps and long presses do nothing. /// /// The grid is one raw `Listener` (no gesture arena), so this tracks the /// pointers itself. @@ -81,6 +82,10 @@ class GridTouchTracker { final Duration longPressTimeout; + /// Whether a long press starts the grid's drag. Off in the read-only + /// mobile layout (T-1): there a held finger that moves still pans. + bool longPressEnabled = true; + GridTouchPhase _phase = GridTouchPhase.none; GridTouchPhase get phase => _phase; @@ -104,7 +109,9 @@ class GridTouchTracker { _velocity = VelocityTracker.withKind(e.kind) ..addPosition(e.timeStamp, e.position); _longPress?.cancel(); - _longPress = Timer(longPressTimeout, _onLongPressTimer); + _longPress = longPressEnabled + ? Timer(longPressTimeout, _onLongPressTimer) + : null; case 2: _longPress?.cancel(); if (_phase == GridTouchPhase.drag) onDragCancel(); diff --git a/wonky_sheets/lib/features/grid/paint/layers/overlay_layers.dart b/wonky_sheets/lib/features/grid/paint/layers/overlay_layers.dart index 7eccd526..1f730c19 100644 --- a/wonky_sheets/lib/features/grid/paint/layers/overlay_layers.dart +++ b/wonky_sheets/lib/features/grid/paint/layers/overlay_layers.dart @@ -116,6 +116,8 @@ class SelectionLayer extends GridLayer { final c = ctx.controller; final sel = ctx.selection; if (sel.sheetId != ctx.sheetId) return; + // The read-only mobile layout has no cell selection (suite 02 §6.1, T-1). + if (c.readOnlyView) return; final tokens = ctx.tokens; final fill = Paint()..color = tokens.selectionFill; final border = Paint() @@ -381,7 +383,9 @@ class HeaderLayer extends GridLayer { bold: bold, align: TextAlign.center, ); - final sameSheet = sel.sheetId == ctx.sheetId; + // No selected headers in the read-only mobile layout (T-1). + final sameSheet = + sel.sheetId == ctx.sheetId && !ctx.controller.readOnlyView; // Column headers. for (final pane in g.panes) { diff --git a/wonky_sheets/lib/features/tab_bar/sheet_tab_bar.dart b/wonky_sheets/lib/features/tab_bar/sheet_tab_bar.dart index 3ef98ac7..7e3ae8cc 100644 --- a/wonky_sheets/lib/features/tab_bar/sheet_tab_bar.dart +++ b/wonky_sheets/lib/features/tab_bar/sheet_tab_bar.dart @@ -19,9 +19,18 @@ import '../toolbar/command_ui.dart'; /// The sheet tab bar (04 §6): add, all sheets, tabs (rename inline, color /// strip, menu, drag to reorder), scroll arrows and the selection stats. +/// +/// In the read-only mobile layout (suite 02 §6.1, T-1) the bar and its tabs +/// are taller (touch targets) and show the all-sheets menu and the tabs only: +/// no add button, tab menus, scroll arrows (the strip scrolls with a +/// swipe), Explore or selection stats (there is no cell selection). class SheetTabBar extends StatefulWidget { const SheetTabBar({super.key, required this.controller}); + /// Height of the bar and of a tab (desktop: 36 and 30). + static const double mobileHeight = 48; + static const double mobileTabHeight = 42; + final SheetEditorController controller; @override @@ -58,6 +67,7 @@ class _SheetTabBarState extends State { final cs = theme.colorScheme; final ui = CommandUi(context, c.services); final l = ui.l; + final mobile = WonkyLayout.isMobile(context); return ListenableBuilder( listenable: Listenable.merge([c, c.renamingSheet, c.services.registry]), builder: (context, _) { @@ -65,7 +75,7 @@ class _SheetTabBarState extends State { final hidden = c.model.sheets.where((s) => s.record!.hidden).length; return Container( key: const ValueKey('sheets.tabBar'), - height: 36, + height: mobile ? SheetTabBar.mobileHeight : 36, decoration: BoxDecoration( color: cs.muted.withValues(alpha: 0.5), border: Border(top: BorderSide(color: cs.border)), @@ -73,20 +83,24 @@ class _SheetTabBarState extends State { child: Row( children: [ const SizedBox(width: 4), - ShadTooltip( - builder: (_) => Text(l.cmdSheetAdd), - child: ShadIconButton.ghost( - key: const ValueKey('sheets.tab.add'), - width: 30, - height: 30, - iconSize: 16, - icon: const Icon(LucideIcons.plus), - semanticLabel: l.cmdSheetAdd, - enabled: ui.state('sheets.sheet.add').enabled, - onPressed: () => - ui.run('sheets.sheet.add', const {}, InvocationSource.ui), + if (!mobile) + ShadTooltip( + builder: (_) => Text(l.cmdSheetAdd), + child: ShadIconButton.ghost( + key: const ValueKey('sheets.tab.add'), + width: 30, + height: 30, + iconSize: 16, + icon: const Icon(LucideIcons.plus), + semanticLabel: l.cmdSheetAdd, + enabled: ui.state('sheets.sheet.add').enabled, + onPressed: () => ui.run( + 'sheets.sheet.add', + const {}, + InvocationSource.ui, + ), + ), ), - ), WonkyDropdownMenu( entries: () => [ for (final s in c.model.sheets) @@ -109,13 +123,13 @@ class _SheetTabBarState extends State { builder: (_) => Text(l.tabAllSheets), child: ShadIconButton.ghost( key: const ValueKey('sheets.tab.all'), - width: 30, - height: 30, + width: mobile ? 40 : 30, + height: mobile ? 40 : 30, iconSize: 16, icon: Stack( clipBehavior: Clip.none, children: [ - const Icon(LucideIcons.menu, size: 16), + Icon(LucideIcons.menu, size: mobile ? 20 : 16), if (hidden > 0) Positioned( right: -6, @@ -165,6 +179,7 @@ class _SheetTabBarState extends State { controller: c, sheet: sheets[i], ui: ui, + mobile: mobile, onDragStart: () => setState(() => _dragging = sheets[i].id), onDragUpdate: (globalX) => @@ -179,29 +194,33 @@ class _SheetTabBarState extends State { ), ), ), - ShadIconButton.ghost( - key: const ValueKey('sheets.tab.scrollLeft'), - width: 24, - height: 30, - iconSize: 14, - icon: const Icon(LucideIcons.chevronLeft), - semanticLabel: l.tabScrollLeft, - onPressed: () => _scrollBy(-120), - ), - ShadIconButton.ghost( - key: const ValueKey('sheets.tab.scrollRight'), - width: 24, - height: 30, - iconSize: 14, - icon: const Icon(LucideIcons.chevronRight), - semanticLabel: l.tabScrollRight, - onPressed: () => _scrollBy(120), - ), - const SizedBox(width: 8), + if (!mobile) ...[ + ShadIconButton.ghost( + key: const ValueKey('sheets.tab.scrollLeft'), + width: 24, + height: 30, + iconSize: 14, + icon: const Icon(LucideIcons.chevronLeft), + semanticLabel: l.tabScrollLeft, + onPressed: () => _scrollBy(-120), + ), + ShadIconButton.ghost( + key: const ValueKey('sheets.tab.scrollRight'), + width: 24, + height: 30, + iconSize: 14, + icon: const Icon(LucideIcons.chevronRight), + semanticLabel: l.tabScrollRight, + onPressed: () => _scrollBy(120), + ), + const SizedBox(width: 8), + ], RowLoadingStatus(controller: c), - ExploreButton(controller: c), - const SizedBox(width: 4), - SelectionStatsButton(controller: c), + if (!mobile) ...[ + ExploreButton(controller: c), + const SizedBox(width: 4), + SelectionStatsButton(controller: c), + ], const SizedBox(width: 8), ], ), @@ -264,6 +283,7 @@ class _SheetTab extends StatefulWidget { required this.controller, required this.sheet, required this.ui, + required this.mobile, required this.onDragStart, required this.onDragUpdate, required this.onDragEnd, @@ -272,6 +292,9 @@ class _SheetTab extends StatefulWidget { final SheetEditorController controller; final SheetModel sheet; final CommandUi ui; + + /// The read-only mobile layout: taller, larger text, no tab menu. + final bool mobile; final VoidCallback onDragStart; final ValueChanged onDragUpdate; final VoidCallback onDragEnd; @@ -438,6 +461,7 @@ class _SheetTabState extends State<_SheetTab> { widget.sheet.name, overflow: TextOverflow.ellipsis, style: theme.textTheme.small.copyWith( + fontSize: widget.mobile ? 15 : null, fontWeight: active ? FontWeight.w600 : FontWeight.normal, color: active ? cs.foreground : cs.mutedForeground, ), @@ -447,9 +471,12 @@ class _SheetTabState extends State<_SheetTab> { ), ); final tab = Container( - height: 30, + height: widget.mobile ? SheetTabBar.mobileTabHeight : 30, margin: const EdgeInsets.only(right: 2, top: 3), - padding: const EdgeInsets.only(left: 10, right: 2), + padding: EdgeInsets.only( + left: widget.mobile ? 14 : 10, + right: widget.mobile ? 14 : 2, + ), decoration: BoxDecoration( color: active ? cs.background : cs.muted, borderRadius: const BorderRadius.vertical(bottom: Radius.circular(6)), @@ -483,22 +510,23 @@ class _SheetTabState extends State<_SheetTab> { ], label, SheetTabPresence(controller: c, sheetId: widget.sheet.id), - WonkyDropdownMenu( - controller: _menu, - entries: _entries, - builder: (context, menu) => ShadIconButton.ghost( - key: ValueKey('sheets.tab.menu.${widget.sheet.id}'), - width: 22, - height: 22, - iconSize: 12, - icon: Icon( - LucideIcons.chevronDown, - color: cs.mutedForeground, + if (!widget.mobile) + WonkyDropdownMenu( + controller: _menu, + entries: _entries, + builder: (context, menu) => ShadIconButton.ghost( + key: ValueKey('sheets.tab.menu.${widget.sheet.id}'), + width: 22, + height: 22, + iconSize: 12, + icon: Icon( + LucideIcons.chevronDown, + color: cs.mutedForeground, + ), + semanticLabel: widget.ui.l.tabMenu(widget.sheet.name), + onPressed: menu.toggle, ), - semanticLabel: widget.ui.l.tabMenu(widget.sheet.name), - onPressed: menu.toggle, ), - ), ], ), if (color != null) @@ -513,7 +541,7 @@ class _SheetTabState extends State<_SheetTab> { ); return Listener( onPointerDown: (e) { - if (e.buttons == kSecondaryMouseButton) _menu.show(); + if (e.buttons == kSecondaryMouseButton && !widget.mobile) _menu.show(); }, child: GestureDetector( key: (context.findAncestorStateOfType<_SheetTabBarState>())?.keyFor( @@ -528,9 +556,12 @@ class _SheetTabState extends State<_SheetTab> { ); } }, - onDoubleTap: () { - if (c.canEdit) c.renamingSheet.value = widget.sheet.id; - }, + // Mobile: no rename (read-only), and a tap activates at once. + onDoubleTap: widget.mobile + ? null + : () { + if (c.canEdit) c.renamingSheet.value = widget.sheet.id; + }, onHorizontalDragStart: c.canEdit && !renaming ? (_) { _dragged = true; diff --git a/wonky_sheets/test/features/sheet_editor/sheet_editor_screen_test.dart b/wonky_sheets/test/features/sheet_editor/sheet_editor_screen_test.dart index f3d56b57..bced42b0 100644 --- a/wonky_sheets/test/features/sheet_editor/sheet_editor_screen_test.dart +++ b/wonky_sheets/test/features/sheet_editor/sheet_editor_screen_test.dart @@ -1,8 +1,10 @@ +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:wonky_sheets/features/sheet_editor/sheet_editor_screen.dart'; import 'package:wonky_sheets/features/sheet_editor/sheets_document_controller.dart'; +import 'package:wonky_sheets/features/tab_bar/sheet_tab_bar.dart'; import 'package:shad/shad.dart' show ShadTheme; import 'package:wonky_ui/wonky_ui.dart' show DocumentTitleField, WonkyMenubar, WonkyToolbar; @@ -191,5 +193,146 @@ void main() { await tester.pump(const Duration(seconds: 2)); }); } + + /// The center of cell [row], [col] on screen (row header 48 px, column + /// header 24 px, rows 21 px, columns 100 px at 100 %). + Offset cellAt(WidgetTester tester, int row, int col) => + tester.getTopLeft(key('sheets.grid')) + + Offset(48 + col * 100 + 50, 24 + row * 21 + 10); + + Future drag( + WidgetTester tester, + Offset from, + Offset by, { + PointerDeviceKind kind = PointerDeviceKind.touch, + Duration hold = Duration.zero, + }) async { + final g = await tester.startGesture(from, kind: kind); + if (hold > Duration.zero) await tester.pump(hold); + for (var i = 0; i < 10; i++) { + await g.moveBy(by / 10); + await tester.pump(const Duration(milliseconds: 16)); + } + await g.up(); + await tester.pump(const Duration(seconds: 1)); + } + + testWidgets('when tapping or long-pressing cells then nothing is ' + 'selected, and swiping (also after a long press, or with a mouse) ' + 'pans the grid (T-1)', (tester) async { + await pumpScreen(tester, size: const Size(390, 844)); + final ed = doc().editor!; + final before = ed.selection.describe(); + + await tester.tapAt(cellAt(tester, 3, 1), kind: PointerDeviceKind.touch); + await tester.pump(const Duration(milliseconds: 500)); + expect(ed.selection.describe(), before); + + final press = await tester.startGesture( + cellAt(tester, 2, 1), + kind: PointerDeviceKind.touch, + ); + await tester.pump(const Duration(milliseconds: 700)); + await press.up(); + await tester.pump(const Duration(milliseconds: 500)); + expect(ed.selection.describe(), before); + + await drag(tester, cellAt(tester, 20, 1), const Offset(0, -200)); + final y = ed.scroll.y; + expect(y, greaterThan(150)); + expect(ed.selection.describe(), before); + + // A held finger that then moves pans instead of selecting a range. + await drag( + tester, + cellAt(tester, 10, 1), + const Offset(0, -100), + hold: const Duration(milliseconds: 700), + ); + expect(ed.scroll.y, greaterThan(y + 50)); + expect(ed.selection.describe(), before); + + // A mouse in a narrow window pans too. + final y2 = ed.scroll.y; + await drag( + tester, + cellAt(tester, 10, 1), + const Offset(0, -100), + kind: PointerDeviceKind.mouse, + ); + expect(ed.scroll.y, greaterThan(y2 + 50)); + expect(ed.selection.describe(), before); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('when the window is wide then a tap selects the cell (the ' + 'desktop is unchanged)', (tester) async { + await pumpScreen(tester); + final ed = doc().editor!; + await tester.tapAt(cellAt(tester, 3, 1), kind: PointerDeviceKind.touch); + await tester.pump(const Duration(milliseconds: 500)); + expect(ed.selection.describe(), 'B4'); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); + + for (final dark in [false, true]) { + testWidgets('when it shows in ${dark ? 'dark' : 'light'} then the sheet ' + 'tabs are taller and the bar shows the all-sheets menu and the tabs ' + 'only (T-1)', (tester) async { + await pumpScreen(tester, size: const Size(390, 844), dark: dark); + final sheetId = doc().editor!.activeSheetId; + + expect( + tester.getSize(key('sheets.tabBar')).height, + SheetTabBar.mobileHeight, + ); + final tab = find.descendant( + of: key('sheets.tab.$sheetId'), + matching: find.byType(Container), + ); + expect( + tester.getSize(tab.first).height, + greaterThanOrEqualTo(SheetTabBar.mobileTabHeight), + ); + expect(key('sheets.tab.all'), findsOneWidget); + expect(key('sheets.tab.$sheetId'), findsOneWidget); + for (final hidden in [ + 'sheets.tab.add', + 'sheets.tab.scrollLeft', + 'sheets.tab.scrollRight', + 'sheets.tab.explore', + 'sheets.tab.menu.$sheetId', + ]) { + expect(key(hidden), findsNothing, reason: hidden); + } + final theme = ShadTheme.of(tester.element(key('sheets.tabBar'))); + expect(theme.brightness, dark ? Brightness.dark : Brightness.light); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); + } + + testWidgets('when the window is wide then the tab bar keeps its height ' + 'and every control (the desktop is unchanged)', (tester) async { + await pumpScreen(tester); + final sheetId = doc().editor!.activeSheetId; + expect(tester.getSize(key('sheets.tabBar')).height, 36); + for (final shown in [ + 'sheets.tab.add', + 'sheets.tab.all', + 'sheets.tab.scrollLeft', + 'sheets.tab.scrollRight', + 'sheets.tab.explore', + 'sheets.tab.menu.$sheetId', + ]) { + expect(key(shown), findsOneWidget, reason: shown); + } + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); }); } diff --git a/wonky_write/lib/features/editor/controller/write_editor_controller.dart b/wonky_write/lib/features/editor/controller/write_editor_controller.dart index f8dd4709..f511095b 100644 --- a/wonky_write/lib/features/editor/controller/write_editor_controller.dart +++ b/wonky_write/lib/features/editor/controller/write_editor_controller.dart @@ -57,6 +57,7 @@ class WriteEditorController extends ChangeNotifier { requestHandlers: [ ...wonkyRequestHandlers( mode: () => _mode, + selectable: () => _selectable, documentLength: () => mirror.length, onMaxLengthReached: () => _events.add(const EditorNotice(EditorNoticeKind.documentFull)), @@ -449,6 +450,21 @@ class WriteEditorController extends ChangeNotifier { EditorMode _mode; /// Editing, Suggesting or Viewing (Write 04 §31). + bool _selectable = true; + + /// Whether the user can select text (taps, drags, long presses). Off in + /// the read-only mobile layout (suite 02 §6.1, T-1); a layout setting, not + /// a change of the document. Turning it off clears the selection. + bool get selectable => _selectable; + set selectable(bool value) { + if (value == _selectable) return; + _selectable = value; + if (!value && composer.selection != null) { + editor.execute([const ClearSelectionRequest()]); + } + notifyListeners(); + } + EditorMode get mode => _mode; set mode(EditorMode value) { if (value == _mode) return; diff --git a/wonky_write/lib/features/editor/pagination/pagination.dart b/wonky_write/lib/features/editor/pagination/pagination.dart index eebd7c10..1ec2846d 100644 --- a/wonky_write/lib/features/editor/pagination/pagination.dart +++ b/wonky_write/lib/features/editor/pagination/pagination.dart @@ -27,9 +27,21 @@ class PaginationController extends ChangeNotifier { /// that runs before the layout picked up new gaps stays correct. Map _applied = const {}; int _pageCount = 1; + double _tail = 0; Map get gaps => _gaps; + /// Space below the canvas's last page (Write 05 §16: 48 px bottom canvas + /// padding), unscaled. + static const double canvasBottomPadding = 48; + + /// Extra bottom padding of the document layout (scaled): the room between + /// the last block and the last page's bottom edge, plus + /// [canvasBottomPadding]. The page cards are painted under the document, + /// so without it the scroll extent ends at the last line and a page that + /// isn't full can't be scrolled to its bottom (#1). + double get tailPadding => _tail + canvasBottomPadding * _zoom; + /// Number of pages after the last layout. int get pageCount => _pageCount; @@ -152,6 +164,7 @@ class PaginationController extends ChangeNotifier { final newGaps = {}; var shift = 0.0; var page = 0; + double? lastBottom; for (var i = 0; i < nodes.length; i += 1) { final rect = natural[i]; if (rect == null) continue; @@ -202,12 +215,20 @@ class PaginationController extends ChangeNotifier { // Oversized block: it overflows across the seam. page += 1; } + lastBottom = bottom; } final changed = !_sameGaps(newGaps, _gaps); if (changed) _gaps = newGaps; final count = page + 1; - if (changed || count != _pageCount) { + // The last block's bottom margin (the document padding) ends where its + // page ends; the rest of that page is the tail. + final tail = lastBottom == null + ? 0.0 + : (contentBottomOf(page) - lastBottom).clamp(0.0, double.infinity); + final tailChanged = (tail - _tail).abs() > 0.5; + if (tailChanged) _tail = tail; + if (changed || tailChanged || count != _pageCount) { _pageCount = count; notifyListeners(); } @@ -278,19 +299,34 @@ class PaginationStylePhase extends SingleColumnLayoutStylePhase { ) { final gaps = pagination.gaps; pagination.markApplied(gaps); - if (gaps.isEmpty) return viewModel; + final models = viewModel.componentViewModels; + // The layout reaches the last page's bottom edge (see `tailPadding`). + // It is the last block's bottom padding, not the document padding: the + // layout is rebuilt only when a component's view model changes, and the + // padding is outside the component, so its measured size is unchanged. + final tail = pagination.tailPadding; return SingleColumnLayoutViewModel( padding: viewModel.padding, componentViewModels: [ - for (final model in viewModel.componentViewModels) - if (gaps[model.nodeId] case final gap?) - (model.copy() - ..padding = model.padding.add(EdgeInsets.only(top: gap))) - else - model, + for (var i = 0; i < models.length; i += 1) + _padded( + models[i], + top: gaps[models[i].nodeId] ?? 0, + bottom: i == models.length - 1 ? tail : 0, + ), ], ); } + + static SingleColumnLayoutComponentViewModel _padded( + SingleColumnLayoutComponentViewModel model, { + required double top, + required double bottom, + }) { + if (top == 0 && bottom == 0) return model; + return model.copy() + ..padding = model.padding.add(EdgeInsets.only(top: top, bottom: bottom)); + } } /// Paints page cards, header/footer text and page numbers under the document diff --git a/wonky_write/lib/features/editor/setup/editor_factory.dart b/wonky_write/lib/features/editor/setup/editor_factory.dart index 26426a05..31852cd8 100644 --- a/wonky_write/lib/features/editor/setup/editor_factory.dart +++ b/wonky_write/lib/features/editor/setup/editor_factory.dart @@ -47,14 +47,38 @@ bool isTextInsertionRequest(EditRequest request) => request is InsertSoftNewlineAtCaretRequest || request is PasteEditorRequest; +/// Collapses the selections the user makes (double taps, drags, long presses) +/// while [selectable] is false: the read-only mobile layout has no text +/// selection (suite 02 §6.1, T-1). Read-only documents don't show a +/// collapsed selection, and super_editor's gesture interactors expect their +/// selection requests to set one (refusing them crashes the touch +/// interactors). Selections set by content changes still apply. +EditRequestHandler selectionGuard(bool Function() selectable) => + (editor, request) { + if (selectable() || + request is! ChangeSelectionRequest || + request.reason != SelectionReason.userInteraction) { + return null; + } + final selection = request.newSelection; + if (selection == null || selection.isCollapsed) return null; + return ChangeSelectionCommand( + DocumentSelection.collapsed(position: selection.extent), + request.changeType, + request.reason, + ); + }; + /// Request handlers that run before super_editor's own (Write 04 §2.2). List wonkyRequestHandlers({ required EditorMode Function() mode, + bool Function()? selectable, required int Function() documentLength, required void Function() onMaxLengthReached, int maxDocumentChars = 2000000, }) { return [ + if (selectable != null) selectionGuard(selectable), // Mode guard (§31): Viewing refuses every content change. (editor, request) => mode() == EditorMode.viewing && !isNonMutatingRequest(request) diff --git a/wonky_write/lib/features/editor/ui/wonky_editor.dart b/wonky_write/lib/features/editor/ui/wonky_editor.dart index d78aa9ce..8e5c148a 100644 --- a/wonky_write/lib/features/editor/ui/wonky_editor.dart +++ b/wonky_write/lib/features/editor/ui/wonky_editor.dart @@ -28,6 +28,7 @@ import '../paste/editor_clipboard.dart'; import '../plugins/slash_items.dart'; import '../plugins/trigger_queries.dart'; import '../popovers/editor_popovers.dart'; +import '../setup/editor_factory.dart' show selectionGuard; import '../styles/editor_theme.dart'; import '../styles/style_phases.dart'; import '../styles/write_stylesheet.dart'; @@ -139,9 +140,10 @@ class WonkyEditor extends StatefulWidget { final double endInset; /// Left and right padding around the text instead of the page margins - /// (Pages; the page then fills the width) or the pageless padding: the - /// mobile layout's (suite 02 §6.1, T-12). Null: the document's own. Only - /// the rendering changes, not the page setup. + /// (Pages; the page then fills the width) or the pageless padding, where + /// it is also the space above the first and below the last block: the + /// mobile layout's (suite 02 §6.1, T-12, T-1). Null: the document's own. + /// Only the rendering changes, not the page setup. final double? compactPadding; /// Source of OS drops (defaults to the platform's; tests inject one). @@ -609,9 +611,18 @@ class WonkyEditorState extends State { ? SuggestionView.accepted(delta) : SuggestionView.original(delta); final document = const DeltaDocumentMapper().documentFromDelta(viewed); - _previewEditor = createDefaultDocumentEditor( - document: document, - composer: MutableDocumentComposer(), + // No text selection where the controller has none (mobile, T-1). + _previewEditor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: MutableDocumentComposer(), + }, + requestHandlers: [ + selectionGuard(() => controller.selectable), + ...defaultRequestHandlers.cast(), + ], + reactionPipeline: List.from(defaultEditorReactions), + isHistoryEnabled: false, ); } @@ -675,12 +686,15 @@ class WonkyEditorState extends State { ) : compact == null ? style.stylesheet + // Pageless: [compact] also above the first and below the last block. : _paddedStylesheet( style.stylesheet, - _compact( - style.stylesheet.documentPadding ?? EdgeInsets.zero, - compact, + (style.stylesheet.documentPadding ?? EdgeInsets.zero).copyWith( + left: compact, + right: compact, + top: compact, ), + lastBlockBottom: compact, ); _tables ..onUndo = widget.shortcutCallbacks?.onUndo @@ -788,22 +802,50 @@ class WonkyEditorState extends State { : padding.copyWith(left: compact, right: compact); /// [base] with [margins] as document padding (Pages: the page margins; - /// `compactPadding`), memoized: a new stylesheet makes `super_editor` - /// recreate its layout presenter and restyle every block, which must not - /// happen on every keystroke. - Stylesheet _paddedStylesheet(Stylesheet base, EdgeInsets margins) { + /// `compactPadding`) and, when given, [lastBlockBottom] below the last + /// block, memoized: a new stylesheet makes `super_editor` recreate its + /// layout presenter and restyle every block, which must not happen on + /// every keystroke. + Stylesheet _paddedStylesheet( + Stylesheet base, + EdgeInsets margins, { + double? lastBlockBottom, + }) { final cached = _pagesSheet; if (cached != null && identical(cached.base, base) && - cached.margins == margins) { + cached.margins == margins && + cached.lastBlockBottom == lastBlockBottom) { return cached.sheet; } - final sheet = base.copyWith(documentPadding: margins); - _pagesSheet = (base: base, margins: margins, sheet: sheet); + final sheet = base.copyWith( + documentPadding: margins, + addRulesAfter: [ + if (lastBlockBottom != null) + StyleRule( + BlockSelector.all.last(), + (doc, node) => { + Styles.padding: CascadingPadding.only(bottom: lastBlockBottom), + }, + ), + ], + ); + _pagesSheet = ( + base: base, + margins: margins, + lastBlockBottom: lastBlockBottom, + sheet: sheet, + ); return sheet; } - ({Stylesheet base, EdgeInsets margins, Stylesheet sheet})? _pagesSheet; + ({ + Stylesheet base, + EdgeInsets margins, + double? lastBlockBottom, + Stylesheet sheet, + })? + _pagesSheet; /// The last SuperEditor/SuperReader widget and the inputs it was built /// from (see `_build`). diff --git a/wonky_write/lib/features/screen/write_document_controller.dart b/wonky_write/lib/features/screen/write_document_controller.dart index 3e125520..a5590347 100644 --- a/wonky_write/lib/features/screen/write_document_controller.dart +++ b/wonky_write/lib/features/screen/write_document_controller.dart @@ -308,7 +308,7 @@ class WriteDocumentController extends DocumentController { void _applyPageGeometry() { final setup = pageSetup; - view.layout = setup.layout == PageLayoutMode.pages + view.layout = setup.layout == PageLayoutMode.pages && !_mobileView ? LayoutMode.pages : LayoutMode.pageless; final i = item; @@ -612,9 +612,20 @@ class WriteDocumentController extends DocumentController { @override void focusEditor() => editor.focusNode.requestFocus(); - /// The mobile layout shows the document in Viewing (suite 02 §6.1). + /// The mobile layout shows the document in Viewing, always Pageless and + /// without text selection (suite 02 §6.1, T-1). It is layout, not a change + /// of the document: the page setup is unchanged and widening the window + /// restores it. @override - void setReadOnlyView(bool readOnly) => session.setReadOnlyView(readOnly); + void setReadOnlyView(bool readOnly) { + session.setReadOnlyView(readOnly); + editor.selectable = !readOnly; + if (readOnly == _mobileView) return; + _mobileView = readOnly; + _applyPageGeometry(); + } + + bool _mobileView = false; /// Scrolls [offset] into view. void reveal(int offset) => revealOffset?.call(offset); diff --git a/wonky_write/lib/features/screen/write_document_screen.dart b/wonky_write/lib/features/screen/write_document_screen.dart index ca01d7e6..dd020e64 100644 --- a/wonky_write/lib/features/screen/write_document_screen.dart +++ b/wonky_write/lib/features/screen/write_document_screen.dart @@ -225,6 +225,25 @@ class _WriteDocumentScreenState extends State { } Widget _body(BuildContext context, DriveItemView item) { + final body = _phaseBody(context, item); + if (!WonkyLayout.isMobile(context)) return body; + // The read-only mobile layout: a 1 px `border` line between the title + // row and the page, which otherwise blend (like Sheets, suite 02 §6.1, + // T-1). + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + key: const ValueKey('write.mobile.separator'), + height: 1, + child: ColoredBox(color: ShadTheme.of(context).colorScheme.border), + ), + Expanded(child: body), + ], + ); + } + + Widget _phaseBody(BuildContext context, DriveItemView item) { return ValueListenableBuilder( valueListenable: session.phase, builder: (context, phase, _) { diff --git a/wonky_write/lib/features/settings/write_settings.dart b/wonky_write/lib/features/settings/write_settings.dart index 5e6632e8..3f7d52d6 100644 --- a/wonky_write/lib/features/settings/write_settings.dart +++ b/wonky_write/lib/features/settings/write_settings.dart @@ -213,9 +213,10 @@ class WritePageSettings extends StatelessWidget { builder: (context, _) { final l = writeL10n(context); final d = s.prefs.writeDefaults; - final layout = d[WriteSettingsKeys.pageless] == true - ? 'pageless' - : 'pages'; + // Pageless unless the user chose Pages (T-1). + final layout = d[WriteSettingsKeys.pageless] == false + ? 'pages' + : 'pageless'; final format = d[WriteSettingsKeys.exportFormat] as String? ?? 'md'; final hasPageSetup = d[WriteSettingsKeys.pageSetup] != null; String layoutLabel(String v) => diff --git a/wonky_write/lib/features/settings/write_settings_commands.dart b/wonky_write/lib/features/settings/write_settings_commands.dart index 3af322c7..d8123d05 100644 --- a/wonky_write/lib/features/settings/write_settings_commands.dart +++ b/wonky_write/lib/features/settings/write_settings_commands.dart @@ -6,7 +6,9 @@ import '../../commands/write_command.dart'; /// Keys of `UserPreferences.writeDefaults` set by Write settings (Write 05 /// §13.3) that have no command of their own elsewhere. abstract final class WriteSettingsKeys { - /// Default layout of new documents: true = pageless (v1 key). + /// Default layout of new documents: true = pageless (v1 key). Unset is + /// Pageless (T-1); the server applies it when it creates a document + /// (`UserService.defaultPageless`). static const pageless = 'pageless'; /// Default export format id (`md`, `html`, `pdf`, `docx`, `odt`, `txt`). @@ -38,9 +40,9 @@ List> writeSettingsCommands(ShellServices s) => [ ), checked: (ctx, a) => a.string('layout') == null ? null - : (s.prefs.writeDefaults[WriteSettingsKeys.pageless] == true - ? 'pageless' - : 'pages') == + : (s.prefs.writeDefaults[WriteSettingsKeys.pageless] == false + ? 'pages' + : 'pageless') == a.string('layout'), run: (ctx, a) async { await s.prefs.setWriteDefault( diff --git a/wonky_write/lib/features/spellcheck/write_spellcheck.dart b/wonky_write/lib/features/spellcheck/write_spellcheck.dart index ed3483a5..173e3c56 100644 --- a/wonky_write/lib/features/spellcheck/write_spellcheck.dart +++ b/wonky_write/lib/features/spellcheck/write_spellcheck.dart @@ -180,16 +180,27 @@ class WriteSpellcheck extends ChangeNotifier { void _onDocumentChange(DocumentChangeLog log) { if (_engine == null) return; + final removed = []; for (final change in log.changes) { if (change is NodeRemovedEvent) { _dirty.remove(change.nodeId); - if (_errors.remove(change.nodeId) != null) { - styler.clearErrorsForNode(change.nodeId); - } + if (_errors.remove(change.nodeId) != null) removed.add(change.nodeId); } else if (change is NodeDocumentChange) { _dirty.add(change.nodeId); } } + if (removed.isNotEmpty) { + // Not now: this listener may run before super_editor's layout has seen + // the removal, and a style phase marked dirty then makes the layout + // restyle its stale view model, which still has the removed block. It + // throws ("No such position in document") and stops updating (#21). + scheduleMicrotask(() { + if (_disposed) return; + for (final id in removed) { + styler.clearErrorsForNode(id); + } + }); + } _schedule(); } diff --git a/wonky_write/test/features/editor/paste/editor_clipboard_test.dart b/wonky_write/test/features/editor/paste/editor_clipboard_test.dart index 9c03d9e3..ff674439 100644 --- a/wonky_write/test/features/editor/paste/editor_clipboard_test.dart +++ b/wonky_write/test/features/editor/paste/editor_clipboard_test.dart @@ -1,6 +1,7 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import 'package:flutter_test/flutter_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/paste/clipboard_codec.dart'; import 'package:wonky_write/features/editor/paste/editor_clipboard.dart'; import 'package:wonky_write/features/editor/paste/system_clipboard.dart'; @@ -184,4 +185,76 @@ void main() { expect(h.plainText, '# a\nb\n'); }, ); + + group('Given text with consecutive line breaks (#21)', () { + /// Redoes [change] like the redo handler: applies it again. + void redo(LocalDeltaChange change) => h.controller.applyLocal( + change.delta, + label: 'Redo', + source: LocalChangeSource.undo, + selectionAfter: change.selectionAfter, + ); + + for (final (name, content) in [ + ('plain text', const ClipboardContent(plainText: 'one\n\n\ntwo')), + ( + 'HTML', + const ClipboardContent( + plainText: 'one\n\n\ntwo', + html: '
one


two
', + ), + ), + ]) { + test('when pasting $name then each break is a paragraph, deleting ' + 'them restores the text and paste and deletes undo and redo', () { + setUpWith(Delta()..insert('abc\n')); + h.select(3); + + expect(clipboard.pasteContent(content), isTrue); + final pasted = h.plainText; + expect(pasted, startsWith('abcone\n\n\ntwo\n')); + expect(h.changes.single.label, 'Paste'); + + // Backspace over "two" and the three breaks. + for (var i = 0; i < 6; i += 1) { + h.backspace(); + } + expect(h.plainText, startsWith('abcone\n')); + expect(h.plainText, isNot(contains('two'))); + expect(h.controller.document.first.id, isNotNull); + + // Undo every step, the paste last; then redo them all. + final steps = List.of(h.changes); + for (final change in steps.reversed) { + h.undo(change); + } + expect(h.plainText, 'abc\n'); + for (final change in steps) { + redo(change); + } + expect(h.plainText, startsWith('abcone\n')); + expect(h.plainText, isNot(contains('two'))); + }); + } + + test('when pasting HTML with line breaks (br) then they are soft breaks ' + 'that backspace removes one at a time', () { + setUpWith(Delta()..insert('\n')); + h.select(0); + clipboard.pasteContent( + const ClipboardContent( + plainText: 'one\n\n\ntwo', + html: '

one


two

', + ), + ); + expect(h.plainText, 'one


two\n'); + for (var i = 0; i < 4; i += 1) { + h.backspace(); + } + expect(h.plainText, 'one

\n'); + h.backspace(); + h.backspace(); + expect(h.plainText, 'one\n'); + }); + }); } diff --git a/wonky_write/test/features/editor/ui/wonky_editor_pagination_test.dart b/wonky_write/test/features/editor/ui/wonky_editor_pagination_test.dart index 094b6c45..40c08e84 100644 --- a/wonky_write/test/features/editor/ui/wonky_editor_pagination_test.dart +++ b/wonky_write/test/features/editor/ui/wonky_editor_pagination_test.dart @@ -20,6 +20,7 @@ Future _pumpPages( Delta delta, { EditorImageServices images = const NetworkImageServices(), LayoutMode layout = LayoutMode.pages, + ScrollController? scroll, }) async { tester.view.physicalSize = const Size(1200, 900); tester.view.devicePixelRatio = 1; @@ -34,6 +35,7 @@ Future _pumpPages( controller: h.controller, documentTitle: 'Test doc', imageServices: images, + scrollController: scroll, ), ), ), @@ -242,6 +244,60 @@ void main() { ); }); + group('Given the Pages layout and a page that is not full (#1)', () { + /// How far below the top of the canvas the user can scroll to. + double scrollableBottom(ScrollController scroll) => + scroll.position.maxScrollExtent + scroll.position.viewportDimension; + + testWidgets( + 'when the document is one line then it scrolls to the bottom of the page and its padding', + (tester) async { + final scroll = ScrollController(); + addTearDown(scroll.dispose); + await _pumpPages(tester, Delta()..insert('Short\n'), scroll: scroll); + + final pagination = _state(tester).pagination; + final pageBottom = pagination.setup.size.height * pagination.zoom; + expect(pagination.pageCount, 1); + expect( + scrollableBottom(scroll), + greaterThanOrEqualTo( + pageBottom + PaginationController.canvasBottomPadding, + ), + ); + + scroll.jumpTo(scroll.position.maxScrollExtent); + await tester.pump(); + expect(scroll.offset, greaterThan(0)); + }, + ); + + testWidgets( + 'when the last page is partly full then the scroll extent reaches that page\x27s bottom', + (tester) async { + final scroll = ScrollController(); + addTearDown(scroll.dispose); + final h = await _pumpPages(tester, _paragraphs(40), scroll: scroll); + + final pagination = _state(tester).pagination; + final pages = pagination.pageCount; + expect(pages, greaterThan(1)); + final lastPageBottom = + (pages - 1) * pagination.pageStride + + pagination.setup.size.height * pagination.zoom; + final lastText = _rectOf(h, h.controller.document.last.id).bottom; + expect(lastText, lessThan(lastPageBottom - 100)); + expect( + scrollableBottom(scroll), + closeTo( + lastPageBottom + PaginationController.canvasBottomPadding, + 24, + ), + ); + }, + ); + }); + group('Given the Pageless layout', () { testWidgets( 'when an image is taller than a page then it keeps its natural height', diff --git a/wonky_write/test/features/screen/write_document_screen_test.dart b/wonky_write/test/features/screen/write_document_screen_test.dart index 70203228..5fcaa72d 100644 --- a/wonky_write/test/features/screen/write_document_screen_test.dart +++ b/wonky_write/test/features/screen/write_document_screen_test.dart @@ -1,7 +1,19 @@ +import 'dart:convert'; + import 'package:flutter/material.dart' show Material, MaterialType; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; +import 'package:super_editor/super_editor.dart' + show + ChangeSelectionRequest, + DocumentPosition, + DocumentSelection, + SelectionChangeType, + SelectionReason, + StandardEditables, + SuperReader, + TextNode; import 'package:shad/shad.dart'; import 'package:wonky_app/testing.dart'; import 'package:wonky_app/wonky_app.dart'; @@ -410,6 +422,40 @@ void main() { ); } + group('Given a new document (T-1)', () { + for (final (name, layout) in [ + ('the page setup the server gives new documents', 'pageless'), + ('a user default of Pages', 'pages'), + ]) { + testWidgets( + 'when it opens with $name then it shows ${layout == 'pageless' ? 'Pageless' : 'Pages'}', + (tester) async { + // What `DriveService.newDocumentPageSetup` stores. + server.meta = DocumentMetaView( + pageSetupJson: jsonEncode( + PageSetup.defaultsFor('en').toJson()..['layout'] = layout, + ), + ); + await pumpScreen(tester); + + final pageless = layout == 'pageless'; + expect( + session.editor.view.layout, + pageless ? LayoutMode.pageless : LayoutMode.pages, + ); + expect( + find.byKey(const ValueKey('write.page')), + pageless ? findsNothing : findsOneWidget, + ); + // The rest of the page setup is the locale default. + expect(session.editor.view.pageSetup.margins.left, 96); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }, + ); + } + }); + group('Given a narrow (mobile) window', () { Future close(WidgetTester tester) async { await tester.pumpWidget(const SizedBox()); @@ -479,28 +525,150 @@ void main() { tester.getTopLeft(find.text('Hello world', findRichText: true)).dx - tester.getTopLeft(find.byType(WonkyEditor)).dx; - for (final layout in LayoutMode.values) { - testWidgets('when a ${layout.name} document shows then the text has ' - '16 px beside it instead of the margins (T-12)', (tester) async { - await pumpScreen(tester, size: const Size(390, 844)); - session.editor.view.layout = layout; + testWidgets('when a Pages document opens then it shows Pageless, and ' + 'Pages again once the window is wide; the page setup is unchanged ' + '(T-1)', (tester) async { + // The fake server's document has the default page setup: Pages. + await pumpScreen(tester, size: const Size(390, 844)); + await tester.pump(); + + expect(session.meta.value.pageSetup.layout, PageLayoutMode.pages); + expect(session.editor.view.layout, LayoutMode.pageless); + expect(find.byKey(const ValueKey('write.page')), findsNothing); + + tester.view.physicalSize = const Size(1440, 900); + await tester.pump(); + await tester.pump(); + expect(session.editor.view.layout, LayoutMode.pages); + expect(find.byKey(const ValueKey('write.page')), findsOneWidget); + await close(tester); + }); + + testWidgets('when the document shows then the text has 16 px beside and ' + 'above it instead of the margins (T-12, T-1)', (tester) async { + await pumpScreen(tester, size: const Size(390, 844)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final editor = tester.widget(find.byType(WonkyEditor)); + expect(editor.compactPadding, WriteDocumentScreen.mobileTextPadding); + expect(textInset(tester), closeTo(16, 1)); + // The first block (the title) starts 16 px below the separator, not + // the pageless 48 px. + final top = + tester + .getTopLeft( + find.descendant( + of: find.byType(WonkyEditor), + matching: find.text('Plan', findRichText: true), + ), + ) + .dy - + tester.getTopLeft(find.byType(WonkyEditor)).dy; + expect(top, lessThan(24)); + // Below the last block: 16 px instead of the pageless 96 px. + final scrollable = tester.state( + find + .descendant( + of: find.byType(WonkyEditor), + matching: find.byType(Scrollable), + ) + .first, + ); + final viewport = tester.getSize(find.byType(WonkyEditor)).height; + final position = scrollable.position; + final contentHeight = position.maxScrollExtent + viewport; + final lastBottom = + tester.getBottomLeft(find.text('Money', findRichText: true)).dy - + tester.getTopLeft(find.byType(WonkyEditor)).dy; + if (position.maxScrollExtent > 0) { + expect(contentHeight - lastBottom, lessThan(40)); + } + await close(tester); + }); + + for (final dark in [false, true]) { + testWidgets('when the document shows in ${dark ? 'dark' : 'light'} ' + 'then a 1 px border line separates the title row from the page ' + '(T-1)', (tester) async { + await pumpScreen(tester, size: const Size(390, 844), dark: dark); await tester.pump(); - await tester.pump(const Duration(milliseconds: 50)); - expect(session.editor.view.layout, layout); - - final editor = tester.widget(find.byType(WonkyEditor)); - expect(editor.compactPadding, WriteDocumentScreen.mobileTextPadding); - expect(textInset(tester), closeTo(16, 1)); - if (layout == LayoutMode.pages) { - // The page fills the width; its setup keeps the 1-inch margins. - final page = tester.getRect(find.byKey(const ValueKey('write.page'))); - expect(page.width, tester.getSize(find.byType(WonkyEditor)).width); - expect(session.editor.view.pageSetup.margins.left, 96); - } + + final line = find.byKey(const ValueKey('write.mobile.separator')); + expect(line, findsOneWidget); + expect(tester.getSize(line).height, 1); + final color = tester + .widget( + find.descendant(of: line, matching: find.byType(ColoredBox)), + ) + .color; + final theme = dark ? WonkyTheme.dark() : WonkyTheme.light(); + expect(color, theme.colorScheme.border); + // Right below the title row, above the page. + expect( + tester.getTopLeft(line).dy, + lessThanOrEqualTo(tester.getTopLeft(find.byType(WonkyEditor)).dy), + ); await close(tester); }); } + testWidgets('when the user double-taps or long-presses a word then no ' + 'text is selected (T-1)', (tester) async { + await pumpScreen(tester, size: const Size(390, 844)); + await tester.pump(); + expect(session.editor.selectable, isFalse); + + final word = tester.getCenter( + find.text('Hello world', findRichText: true), + ); + await tester.tapAt(word); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(word); + await tester.pump(const Duration(milliseconds: 400)); + await tester.longPressAt(word); + await tester.pump(const Duration(milliseconds: 400)); + + // Nothing is selected: at most a collapsed position, which a + // read-only document doesn't show. + final reader = tester.widget(find.byType(SuperReader)); + expect(reader.editor.composer.selection?.isCollapsed ?? true, isTrue); + expect(session.editor.selection?.isCollapsed ?? true, isTrue); + expect(tester.takeException(), isNull); + + // What the reader's gestures ask for (a word, a drag) is collapsed. + final node = reader.editor.document.first as TextNode; + reader.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: node.id, + nodePosition: node.beginningPosition, + ), + extent: DocumentPosition( + nodeId: node.id, + nodePosition: node.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + await tester.pump(); + expect(reader.editor.composer.selection!.isCollapsed, isTrue); + expect( + reader.editor.composer.selection!.extent.nodePosition, + node.endPosition, + ); + + // The desktop selects again. + tester.view.physicalSize = const Size(1440, 900); + await tester.pump(); + await tester.pump(); + expect(session.editor.selectable, isTrue); + await close(tester); + }); + testWidgets('when the window is wide then the page keeps its margins and ' 'width (the desktop is unchanged)', (tester) async { await pumpScreen(tester); diff --git a/wonky_write/test/features/settings/write_settings_test.dart b/wonky_write/test/features/settings/write_settings_test.dart index daf8173b..248ea808 100644 --- a/wonky_write/test/features/settings/write_settings_test.dart +++ b/wonky_write/test/features/settings/write_settings_test.dart @@ -176,6 +176,15 @@ void main() { test('when the page and export defaults are invoked then they are ' 'stored, and invalid values fail', () async { + // Unset: new documents are Pageless (T-1). + expect( + h.services.dispatcher.stateOf('write.prefs.setDefaultLayout', { + 'layout': 'pageless', + }).checked, + isTrue, + ); + await h.invoke('write.prefs.setDefaultLayout', {'layout': 'pages'}); + expect(h.services.prefs.writeDefaults['pageless'], false); await h.invoke('write.prefs.setDefaultLayout', {'layout': 'pageless'}); expect(h.services.prefs.writeDefaults['pageless'], true); await h.invoke('write.prefs.setExportFormat', {'format': 'docx'}); diff --git a/wonky_write/test/features/spellcheck/write_spellcheck_test.dart b/wonky_write/test/features/spellcheck/write_spellcheck_test.dart index 96c48284..2bd0b08f 100644 --- a/wonky_write/test/features/spellcheck/write_spellcheck_test.dart +++ b/wonky_write/test/features/spellcheck/write_spellcheck_test.dart @@ -1,6 +1,7 @@ import 'package:dart_quill_delta/dart_quill_delta.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; import 'package:super_editor/super_editor_test.dart'; import 'package:wonky_app/spellcheck.dart' show SpellDictionaries; import 'package:wonky_write/features/editor/applier/delta_applier.dart'; @@ -224,6 +225,30 @@ void main() { await _settle(tester); }); + testWidgets('when a block with misspellings is merged away then the editor ' + 'keeps rendering (#21)', (tester) async { + // Removing a flagged block cleared its underlines from the document + // listener, before super_editor's layout had seen the removal: the + // layout restyled its stale view model and threw "No such position in + // document" / a null check, and stopped updating. + final (h, spell) = await _pump(tester, Delta()..insert('Good\nborwn\n')); + expect(_flagged(spell), ['borwn']); + final second = h.controller.document.last.id; + + await tester.placeCaretInParagraph(second, 0); + await tester.pressBackspace(); + await _settle(tester); + + expect(tester.takeException(), isNull); + expect(h.plainText, 'Goodborwn\n'); + expect(h.controller.document.getNodeById(second), isNull); + await tester.pressBackspace(); + await _settle(tester); + expect(tester.takeException(), isNull); + expect(h.plainText, 'Gooborwn\n'); + expect(find.text('Gooborwn', findRichText: true), findsOneWidget); + }); + testWidgets('when turning spellcheck off then the underlines go away', ( tester, ) async {