fix(post-composer): restore mention autocomplete suggestions#306
fix(post-composer): restore mention autocomplete suggestions#306spe1020 merged 10 commits intozapcooking:mainfrom
Conversation
72034b9 to
7611c1a
Compare
There was a problem hiding this comment.
Pull request overview
This PR restores mention autocomplete visibility in the post composer by portaling MentionDropdown to document.body so its position: fixed caret coordinates remain viewport-relative even inside transformed modals. The diff also includes several stacked UX fixes in the recipe editor and app shell.
Changes:
- Portal
MentionDropdowntodocument.bodyand simplify PostComposer’s input handling to rely on the mention controller as the source of truth. - Improve recipe editor UX (auto-save drafts, ensure markdown preview links open in new tabs, add media-by-URL, fix drag-reorder drop behavior).
- App-shell tweaks (suppress fatal UI for benign window errors, prevent dark-mode logo flash, version bump).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/MentionDropdown.svelte | Portals dropdown to document.body to avoid transformed-modal containing-block issues. |
| src/components/PostComposer.svelte | Removes redundant state assignment in on:input, delegating to mention controller callbacks. |
| src/routes/create/+page.svelte | Adds auto-save state, signature-based change detection, and silent debounced draft saving. |
| src/lib/parser.ts | Forces target/_blank behavior to survive DOMPurify sanitization for markdown-rendered links. |
| src/components/MediaUploader.svelte | Adds a collapsible “Add images by URL” UI with basic URL validation and dedupe. |
| src/components/StringComboBox.svelte | Improves drag-and-drop reorder to support “drop after” (incl. last item) with visual indicators. |
| src/components/ErrorBoundary.svelte | Changes handling of window errors to log-only and removes the previous svelte:window binding. |
| src/components/Header.svelte | Adds logo marker classes for pre-Tailwind flash prevention. |
| src/components/DesktopSideNav.svelte | Adds logo marker classes for pre-Tailwind flash prevention. |
| src/app.html | Inlines early CSS to hide the incorrect theme logo before Tailwind loads. |
| package.json | Bumps app version. |
Comments suppressed due to low confidence (1)
src/routes/create/+page.svelte:78
onMountnow continues running aftergoto('/login')(it isn't awaited/returned), so draft initialization + event listeners + auto-save arming can still happen briefly during redirect. Considerawait goto('/login')andreturn(or justreturnafter callinggoto) to avoid side effects when the user isn't authenticated.
onMount(async () => {
if ($userPublickey == '') goto('/login');
// Initialize draft store
initializeDraftStore();
// Check for draft ID in URL
const draftId = $page.url.searchParams.get('draft');
if (draftId) {
loadDraftById(draftId);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -106,8 +105,6 @@ | |||
| }); | |||
There was a problem hiding this comment.
handleError() is no longer wired up to anything after removing <svelte:window on:error={handleError} />, so the component will never set error and will never render the fallback UI. If you still want to support the “programmatic CustomEvent path” described in the comment, add an explicit event listener that routes those CustomEvents to handleError (and keep the separate logging-only path for native ErrorEvents), or otherwise remove the dead error UI/state if the boundary is intended to be log-only now.
| // Serialized form of all draft fields — drives change detection for auto-save | ||
| $: draftSignature = JSON.stringify([ | ||
| title, | ||
| $images, |
There was a problem hiding this comment.
draftSignature is assigned in a reactive statement but is never declared (no let draftSignature anywhere in the component). With TS strict this will fail compilation (draftSignature is not defined). Declare it in the <script> block (e.g., initialize to an empty string) before first use in onMount/runAutoSave and the reactive assignment.
7611c1a to
014776d
Compare
Tailwind's dark:hidden / dark:block on the logo <img> tags only take effect once the main CSS bundle loads. In dev (and occasional edge cases in prod) there's a brief window where both logos are visible and the light one paints first. Add a tiny inline <style> in app.html keyed on html.dark so the correct logo is selected before any external CSS arrives; tag the <img> pairs with logo-light / logo-dark.
A subtle "Add images by URL" toggle under the media grid lets users paste a direct image/video URL instead of uploading. First URL added becomes the cover photo (satisfying the publish requirement). Works across all three recipe editors (create, fork, gated) via the shared MediaUploader component.
DOMPurify can strip target/rel on <a> in some configurations, which caused markdown-preview links in the recipe editor to navigate in-place and destroy unsaved edits. Add an afterSanitizeAttributes hook that reapplies target=_blank + rel=noopener noreferrer on every anchor post-sanitization.
Watch the 11 draft fields via a JSON signature; 2s after the last edit, save to localStorage (and queue the debounced relay sync) so users don't lose work. Skips while empty and while a manual save is in flight; updates the URL with ?draft=<id> so reloads resume the same draft.
Auto-save was toggling isSavingDraft and writing a transient status message, which swapped the Save Draft button to "Saving…" and shifted the button row every 2s while typing — the editor appeared to jump around. The URL was also rewritten on every save. Make auto-save fully silent: no button state toggle, no message flash, and only write ?draft=<id> the first time a new draft gets an id. Manual save keeps its full feedback, and the Save Draft button's cloud icon still reflects relay sync state once the background publisher settles.
Two issues made reordering the last items nearly impossible: - handleDrop always inserted before the target, so dropping on the last item never moved the dragged item past it. Detect whether the cursor is in the target's top or bottom half and insert before or after accordingly. A top/bottom box-shadow marker shows which side the drop will land on (no layout shift). - The 8px flex gap between items was dead space — the drop zone only covered each pill. Drop gap-2 and give each <li> py-1 padding (first:pt-0 last:pb-0) so the hit area extends into the gap; the visible pill moves to an inner <div>. Total vertical spacing between items is unchanged.
- parser.ts: use a dedicated DOMPurify instance for markdown so the afterSanitizeAttributes hook doesn't leak target=_blank behavior into every other sanitize() call in the app (e.g. the longform editor). - MediaUploader.svelte: normalize the pasted URL via `parsed.toString()` before the dedupe check and before storing, so equivalent URLs (`example.com` vs `example.com/`) collapse to one entry. - MediaUploader.svelte: add `aria-expanded` + `aria-controls` to the "Add images by URL" disclosure toggle for screen-reader support. - create/+page.svelte: allow auto-save to persist when all fields are cleared on an existing draft (previously the delete-everything state was never saved, so reloading brought the old content back). - create/+page.svelte: declare `draftSignature` explicitly and debounce its computation to 250ms so we don't re-JSON.stringify the entire draft on every keystroke.
<svelte:window on:error> was catching native ErrorEvents but treating them as CustomEvents, so the details were lost and every spurious window error — including the ones iOS Safari fires during share / backgrounding / service-worker transitions — nuked the entire app with an "Oops! Something went wrong" screen. Remove the svelte:window binding and align the window 'error' listener with the existing 'unhandledrejection' listener: log-only, no fatal UI. The programmatic CustomEvent path is preserved for child components that want to surface a fallback deliberately.
After the earlier patch removed `<svelte:window on:error={handleError} />`
to stop benign window errors from nuking the app, `handleError` had no
caller and the entire fallback UI / retry / error-state code was
effectively dead — RecipeErrorBoundary and FeedErrorBoundary would
never render their fallback.
Re-wire `handleError` to a dedicated `window` CustomEvent
(`zc:fatal-error`). Child components that hit a genuinely fatal error
can dispatch:
window.dispatchEvent(new CustomEvent('zc:fatal-error', {
detail: { error, errorInfo }
}));
to surface the boundary's fallback UI. Native ErrorEvents are still
logged-only, same as before.
Typing `@<name>` in the new kind:1 post composer was not showing any suggestions. The dropdown component WAS rendering with the right state (`show=true`, populated `suggestions`), but it was positioned off-screen because of a CSS containing-block issue introduced by zapcooking#280. Root cause: zapcooking#280 switched MentionDropdown to `position: fixed` to escape the composer's overflow container. Per the CSS spec, `fixed` positions against the viewport unless an ancestor has `transform`, `filter`, or `perspective` — in which case that ancestor becomes the containing block. Modal's <dialog> uses Tailwind's `-translate-x-1/2 -translate-y-1/2` (a permanent `transform`), so the dropdown's `top`/`left` values — computed from the caret's `getBoundingClientRect()` in viewport coordinates — were being interpreted relative to the dialog box instead, pushing the dropdown off-screen. Fix: portal the dropdown directly to `document.body` when `show` is true. It now lives outside the transformed dialog subtree, so `position: fixed` is correctly viewport-relative and the caret-based coordinates land where expected. Also drop a redundant double-assignment in PostComposer's on:input handler (also from zapcooking#280) so the composer matches the working ReplyComposer pattern — the controller's `onTextChange` callback is already the single source of truth for composer text state.
014776d to
42fc0aa
Compare
Summary
Typing
@<name>in the new kind:1 post composer was not showing any mention suggestions at all — no dropdown, not even the "Searching..." state.Root cause
PR #280 switched
MentionDropdowntoposition: fixedto escape the composer's overflow container. Per the CSS spec,position: fixedis positioned against the viewport unless an ancestor hastransform,filter, orperspective— in which case that ancestor becomes the containing block.Modal.svelte's<dialog>uses Tailwind's-translate-x-1/2 -translate-y-1/2(a permanenttransform: translate(...)), so the dropdown'stop/leftvalues — computed from the caret'sgetBoundingClientRect()in viewport coordinates — were being interpreted relative to the dialog box. The dropdown was rendering with the correct state (verified via debug logs showingshow=true, populated suggestions, the search timer firing), just painted far off-screen.Fix
Portal
MentionDropdowntodocument.bodywhen rendered. It now lives outside the transformed dialog subtree, soposition: fixedis correctly viewport-relative and the caret-based coordinates land where expected. ReplyComposer and every other call site benefit from this too.Also drop a redundant double-assignment in PostComposer's
on:inputhandler (also from #280) so the composer matches ReplyComposer's working pattern — the controller'sonTextChangecallback is already the single source of truth for composer text state.Notes
MentionDropdown.svelteandPostComposer.svelte.Test plan
@s— dropdown appears anchored to caret with matching profiles.@seth— dropdown narrows to matches.[nip19]/+page.sveltereply composer.