Skip to content

Commit 29a8aed

Browse files
committed
fix(pwa): wrapping titles, personal check-off, borderless page editor, br hard breaks (WI-1331)
Mobile surface follow-ups from testing the new PWA: - Item/page titles are auto-growing textareas now: long titles wrap instead of scrolling out of view, and Enter moves to the description. - Personal tab: completed tasks leave the checklist (query filters status_completed = false, newest first); check-off is optimistic with rollback + toast, and the check is a 44px touch target with the small circle inside — the 24px target swallowed taps on phones. - Page edits use the borderless Linear-style editor (hero title + content, sticky safe-area action bar) instead of the old boxed form. - Milkdown now parses <br>/<br/>/<br /> html as hard breaks via a remark transformer registered through remarkPluginsCtx at config time — stored tags showed up as literal text (or vanished, 7.19) and round-tripped forever; converted content serializes back as real markdown hard breaks.
1 parent 85a4175 commit 29a8aed

7 files changed

Lines changed: 250 additions & 50 deletions

File tree

frontend/src/lib/editors/MilkdownEditor.svelte

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script>
22
import { onMount, onDestroy } from 'svelte';
33
import { useEventListener } from 'runed';
4-
import { Editor, rootCtx, defaultValueCtx, editorViewOptionsCtx, editorViewCtx, serializerCtx } from '@milkdown/kit/core';
4+
import { Editor, rootCtx, defaultValueCtx, editorViewOptionsCtx, editorViewCtx, serializerCtx, remarkPluginsCtx } from '@milkdown/kit/core';
55
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBulletListCommand, wrapInOrderedListCommand, toggleInlineCodeCommand } from '@milkdown/kit/preset/commonmark';
66
import { gfm, toggleStrikethroughCommand } from '@milkdown/kit/preset/gfm';
77
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener';
@@ -20,6 +20,7 @@
2020
import MentionPicker from '../pickers/MentionPicker.svelte';
2121
import { mentionDecorationPlugin } from './milkdown-mention-mark.js';
2222
import { linkSanitizerPlugin } from './milkdown-link-sanitizer.js';
23+
import { rewriteBreakHTML } from './milkdown-hardbreak.js';
2324
import { excalidrawBlock } from './milkdown-excalidraw-block.svelte.js';
2425
import PageDiagramModal from '../features/pages/PageDiagramModal.svelte';
2526
import { highlightCodeBlocks } from './code-highlight.js';
@@ -458,6 +459,14 @@
458459
.config((ctx) => {
459460
ctx.set(rootCtx, editorElement);
460461
ctx.set(defaultValueCtx, initialContent || '');
462+
// Parse `<br>`-style html as hard breaks (see milkdown-hardbreak.js).
463+
// Registered through remarkPluginsCtx here — config runs before the
464+
// schema step snapshots the remark processor; a `.use($remark)`
465+
// plugin appends after that snapshot and never takes effect.
466+
ctx.update(remarkPluginsCtx, (plugins) => [
467+
...plugins,
468+
{ plugin: rewriteBreakHTML, options: {} },
469+
]);
461470
ctx.get(listenerCtx).markdownUpdated((ctx, markdown) => {
462471
// Listener notifications can be delivered after a newer editor
463472
// transaction (for example, selecting an @ mention). Always
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Milkdown serializes hard breaks in the middle of a paragraph as literal
2+
// `<br />` HTML, but its hardbreak schema only parses remark `break` nodes —
3+
// the HTML spellings come back as visible text (or get dropped) in the visual
4+
// editor and then round-trip forever. This remark plugin rewrites
5+
// `<br>`-style html nodes into real break nodes before Milkdown maps the
6+
// tree, so the editor shows a line break and the next save normalizes the
7+
// content to Markdown hard breaks.
8+
9+
const BREAK_HTML = /^<br\s*\/?>$/i;
10+
11+
// Parents whose children are inline: a bare break node is valid there.
12+
const INLINE_PARENTS = new Set(['paragraph']);
13+
14+
// Block containers where a stray break must be wrapped in a paragraph to stay
15+
// valid mdast (a break is an inline node).
16+
const BLOCK_PARENTS = new Set(['root', 'blockquote', 'listItem']);
17+
18+
function isBreakHTML(node) {
19+
return (
20+
node?.type === 'html' &&
21+
typeof node.value === 'string' &&
22+
BREAK_HTML.test(node.value.trim())
23+
);
24+
}
25+
26+
function replacementFor(parent) {
27+
if (INLINE_PARENTS.has(parent.type)) return { type: 'break' };
28+
if (BLOCK_PARENTS.has(parent.type))
29+
return { type: 'paragraph', children: [{ type: 'break' }] };
30+
return null;
31+
}
32+
33+
function transform(node) {
34+
if (!node || !Array.isArray(node.children)) return;
35+
const children = node.children;
36+
for (let i = 0; i < children.length; i++) {
37+
const child = children[i];
38+
if (isBreakHTML(child)) {
39+
const replacement = replacementFor(node);
40+
if (replacement) children[i] = replacement;
41+
continue;
42+
}
43+
transform(child);
44+
}
45+
}
46+
47+
/**
48+
* Unified plugin (attacher): convert `<br>`, `<br/>`, and `<br />` html nodes
49+
* (inline and block level) into hard break nodes. Wired into the editor via
50+
* `ctx.update(remarkCtx, (processor) => processor.use(rewriteBreakHTML))` —
51+
* config-time updates are deterministic, unlike `$remark` whose plugin races
52+
* the schema step that snapshots the remark processor. Exported separately so
53+
* tests can run it through plain unified, without the editor context.
54+
*/
55+
export const rewriteBreakHTML = () => (tree) => transform(tree);

frontend/src/lib/mobile/MobileCreatePage.svelte

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import MobileEditorPage from './MobileEditorPage.svelte';
1010
import MobileConfirmSheet from './MobileConfirmSheet.svelte';
1111
import MobileOptionSheet from './MobileOptionSheet.svelte';
12+
import { autoGrow, enterMovesFocus } from './autoGrowTextarea.js';
1213
import Avatar from '../components/Avatar.svelte';
1314
import {
1415
isCreateSystemFieldAutoManaged,
@@ -43,6 +44,7 @@
4344
4445
let title = $state('');
4546
let description = $state('');
47+
let descriptionField = $state(null);
4648
let workspaceId = $state(null);
4749
let itemTypeId = $state(null);
4850
let itemTypes = $state([]);
@@ -782,19 +784,23 @@
782784
{/if}
783785
784786
<!-- Linear-style borderless hero fields: the title and description are
785-
the form; properties live in the chip bar pinned at the bottom. -->
786-
<input
787+
the form; properties live in the chip bar pinned at the bottom.
788+
The title textarea wraps and grows so long titles stay visible. -->
789+
<textarea
787790
class="hero-title"
788-
type="text"
789791
bind:value={title}
790792
placeholder={isPersonal ? 'Task title' : 'Issue title'}
791793
autocomplete="off"
794+
rows={1}
792795
enterkeyhint="next"
796+
use:autoGrow={title}
797+
use:enterMovesFocus={{ next: descriptionField }}
793798
data-testid="create-title"
794-
/>
799+
></textarea>
795800
<textarea
796801
class="hero-desc"
797802
bind:value={description}
803+
bind:this={descriptionField}
798804
rows={4}
799805
placeholder="Description…"
800806
data-testid="create-description"
@@ -1092,17 +1098,21 @@
10921098
.create { display: flex; flex-direction: column; gap: 0.5rem; }
10931099
.parent { margin: 0 0 0.25rem; font-size: 0.8125rem; color: var(--ds-text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10941100
1095-
/* Linear-style borderless hero fields. */
1101+
/* Linear-style borderless hero fields. The title textarea wraps and grows
1102+
with its content (autoGrow action) so long titles stay fully visible. */
10961103
.hero-title {
10971104
width: 100%;
10981105
margin: 0.75rem 0 0;
10991106
padding: 0;
11001107
border: none;
11011108
background: transparent;
11021109
color: var(--ds-text);
1110+
font-family: inherit;
11031111
font-size: 1.35rem;
11041112
font-weight: var(--font-semibold, 600);
11051113
line-height: 1.25;
1114+
overflow: hidden;
1115+
resize: none;
11061116
}
11071117
.hero-title::placeholder { color: var(--ds-text-subtlest, var(--ds-text-subtle)); font-weight: var(--font-semibold, 600); }
11081118
.hero-desc {

frontend/src/lib/mobile/MobileItemEditPage.svelte

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { formatItemKey } from '../utils/itemKey.js';
66
import MobileEditorPage from './MobileEditorPage.svelte';
77
import MobileConfirmSheet from './MobileConfirmSheet.svelte';
8+
import { autoGrow, enterMovesFocus } from './autoGrowTextarea.js';
89
import { Loader } from '@lucide/svelte';
910
1011
/**
@@ -21,6 +22,7 @@
2122
let loadErrored = $state(false);
2223
let title = $state('');
2324
let description = $state('');
25+
let descriptionField = $state(null);
2426
let saving = $state(false);
2527
let error = $state('');
2628
@@ -176,18 +178,23 @@
176178
</div>
177179
{:else}
178180
<div class="edit-form" data-testid="item-edit-form">
179-
<!-- Linear-style borderless hero fields. -->
180-
<input
181+
<!-- Linear-style borderless hero fields. The title is an auto-growing
182+
textarea: long titles wrap instead of scrolling out of view. -->
183+
<textarea
181184
class="hero-title"
182-
type="text"
183185
bind:value={title}
184186
placeholder="Issue title"
185187
autocomplete="off"
188+
rows={1}
189+
enterkeyhint="next"
190+
use:autoGrow={title}
191+
use:enterMovesFocus={{ next: descriptionField }}
186192
data-testid="item-edit-title"
187-
/>
193+
></textarea>
188194
<textarea
189195
class="hero-desc"
190196
bind:value={description}
197+
bind:this={descriptionField}
191198
rows={12}
192199
placeholder="Description…"
193200
data-testid="item-edit-description"
@@ -236,17 +243,21 @@
236243
gap: 0.5rem;
237244
}
238245
239-
/* Linear-style borderless hero fields. */
246+
/* Linear-style borderless hero fields. The title textarea wraps and grows
247+
with its content (autoGrow action) so long titles stay fully visible. */
240248
.hero-title {
241249
width: 100%;
242250
margin: 0.75rem 0 0;
243251
padding: 0;
244252
border: none;
245253
background: transparent;
246254
color: var(--ds-text);
255+
font-family: inherit;
247256
font-size: 1.35rem;
248257
font-weight: var(--font-semibold, 600);
249258
line-height: 1.25;
259+
overflow: hidden;
260+
resize: none;
250261
}
251262
.hero-title::placeholder { color: var(--ds-text-subtlest, var(--ds-text-subtle)); font-weight: var(--font-semibold, 600); }
252263
.hero-desc {

frontend/src/lib/mobile/MobilePageDetail.svelte

Lines changed: 67 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
import { renderMarkdown } from '../utils/render-markdown.js';
99
import SafeMarkdown from '../components/SafeMarkdown.svelte';
1010
import MobileHeader from './MobileHeader.svelte';
11+
import { autoGrow, enterMovesFocus } from './autoGrowTextarea.js';
1112
import { pageAncestors, pageChildren } from './mobilePagesData.js';
1213
1314
// Phone page reader: rendered markdown, breadcrumb, and sub-page rows.
14-
// Editing is a plain markdown textarea (the Milkdown rich editor stays
15-
// desktop-only) guarded by the page's content hash against lost updates.
15+
// Editing is a borderless full-page form (same Linear-style hero fields as
16+
// the item editors) guarded by the page's content hash against lost updates.
1617
let { workspaceId, pageId } = $props();
1718
1819
let page = $state(null);
@@ -23,6 +24,7 @@
2324
let editing = $state(false);
2425
let draftTitle = $state('');
2526
let draftContent = $state('');
27+
let draftContentField = $state(null);
2628
let saving = $state(false);
2729
// Guard in-place navigation (page → sub-page) against out-of-order loads.
2830
let loadToken = 0;
@@ -143,20 +145,28 @@
143145
<button class="retry" onclick={() => load(++loadToken)} disabled={loading} type="button">Retry</button>
144146
</div>
145147
{:else if editing}
148+
<!-- Borderless Linear-style editor: hero title + content, actions pinned
149+
to the bottom edge. Same modality as the item create/edit pages. -->
146150
<div class="editor" data-testid="mobile-page-editor">
147-
<input
148-
class="title-input"
151+
<textarea
152+
class="hero-title"
149153
bind:value={draftTitle}
154+
rows={1}
155+
enterkeyhint="next"
156+
placeholder="Page title"
157+
use:autoGrow={draftTitle}
158+
use:enterMovesFocus={{ next: draftContentField }}
150159
data-testid="mobile-page-title-input"
151160
aria-label="Page title"
152-
type="text"
153-
/>
161+
></textarea>
154162
<textarea
155-
class="content-input"
163+
class="hero-content"
156164
bind:value={draftContent}
165+
bind:this={draftContentField}
157166
data-testid="mobile-page-content-input"
158167
aria-label="Page content (Markdown)"
159168
spellcheck="false"
169+
placeholder="Write something…"
160170
></textarea>
161171
<div class="editor-actions">
162172
<button class="btn secondary" onclick={cancelEditing} data-testid="mobile-page-editor-cancel" type="button">Cancel</button>
@@ -341,32 +351,57 @@
341351
}
342352
.sub-row :global(.chev) { color: var(--ds-icon-subtle, var(--ds-text-subtle)); flex-shrink: 0; }
343353
344-
.editor { display: flex; flex-direction: column; gap: 0.6rem; padding: 0.75rem 0.875rem 2rem; }
345-
.title-input {
346-
min-height: 44px;
347-
padding: 0.4rem 0.6rem;
348-
border: 1px solid var(--ds-border);
349-
border-radius: var(--radius-lg, 8px);
350-
background-color: var(--ds-surface-raised);
351-
font-size: 1.125rem;
352-
font-weight: var(--font-semibold, 600);
354+
/* Borderless editor fields — same hero treatment as the item editors. */
355+
.editor {
356+
display: flex;
357+
flex-direction: column;
358+
min-height: 100%;
359+
box-sizing: border-box;
360+
padding: 0.75rem 1rem 0;
361+
gap: 0.5rem;
362+
}
363+
.hero-title {
364+
width: 100%;
365+
margin: 0.5rem 0 0;
366+
padding: 0;
367+
border: none;
368+
background: transparent;
353369
color: var(--ds-text);
370+
font-family: inherit;
371+
font-size: 1.35rem;
372+
font-weight: var(--font-semibold, 600);
373+
line-height: 1.25;
374+
overflow: hidden;
375+
resize: none;
354376
}
355-
.content-input {
356-
min-height: 55dvh;
357-
padding: 0.6rem;
358-
border: 1px solid var(--ds-border);
359-
border-radius: var(--radius-lg, 8px);
360-
background-color: var(--ds-surface-raised);
361-
font-family: var(--font-mono, monospace);
362-
font-size: 0.875rem;
363-
line-height: 1.5;
377+
.hero-title::placeholder { color: var(--ds-text-subtlest, var(--ds-text-subtle)); font-weight: var(--font-semibold, 600); }
378+
.hero-content {
379+
width: 100%;
380+
min-height: 50dvh;
381+
padding: 0;
382+
border: none;
383+
background: transparent;
364384
color: var(--ds-text);
365-
resize: vertical;
385+
font-family: inherit;
386+
font-size: max(1rem, 16px);
387+
line-height: 1.55;
388+
resize: none;
389+
}
390+
.hero-content::placeholder { color: var(--ds-text-subtlest, var(--ds-text-subtle)); }
391+
.hero-title:focus,
392+
.hero-content:focus { outline: none; }
393+
394+
.editor-actions {
395+
position: sticky;
396+
bottom: 0;
397+
z-index: 20;
398+
display: flex;
399+
gap: 0.5rem;
400+
justify-content: flex-end;
401+
margin-top: auto;
402+
padding: 0.6rem 0 calc(env(safe-area-inset-bottom, 0px) + 0.75rem);
403+
background: linear-gradient(to top, var(--ds-surface) 65%, transparent);
366404
}
367-
.title-input:focus,
368-
.content-input:focus { outline: 2px solid var(--ds-interactive); outline-offset: -1px; }
369-
.editor-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
370405
.btn {
371406
display: inline-flex;
372407
align-items: center;
@@ -380,12 +415,12 @@
380415
cursor: pointer;
381416
}
382417
.btn.secondary {
383-
border: 1px solid var(--ds-border);
384-
background: var(--ds-surface);
418+
border: none;
419+
background: var(--ds-background-neutral);
385420
color: var(--ds-text);
386421
}
387422
.btn.primary {
388-
border: 1px solid var(--ds-interactive);
423+
border: none;
389424
background: var(--ds-interactive);
390425
color: var(--ds-text-inverse, #fff);
391426
}

0 commit comments

Comments
 (0)