From 332f586d7b5a02802c24d3a98603a928e73a8572 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Nov 2025 15:38:07 -0500 Subject: [PATCH] ``` feat: enhance Unsplash integration with pagination, random images, and portrait orientation - Added pagination support to search endpoint with page parameter and orientation control (defaults to portrait) - Implemented new /random endpoint for fetching random portrait images with configurable count - Enhanced UnsplashPickerComponent with infinite scroll, loading states, and automatic random image loading on open - Added debounced search input, scroll detection for pagination, and improved error --- server/integrations/unsplash.routes.mjs | 46 ++- .../block/block-inline-toolbar.component.ts | 6 +- .../block/blocks/link-block.component.ts | 25 +- .../block/blocks/paragraph-block.component.ts | 1 + .../editor-shell/editor-shell.component.ts | 9 +- .../toolbar/editor-toolbar.component.ts | 14 +- .../unsplash/unsplash-picker-v2.component.ts | 385 ++++++++++++++++++ .../unsplash/unsplash-picker.component.ts | 331 +++++++++++++-- src/app/editor/services/palette.service.ts | 47 +++ vault/.test/Test 1 Markdown copy.md | 3 - vault/tests/nimbus-editor-snapshot.md | 16 +- 11 files changed, 828 insertions(+), 55 deletions(-) create mode 100644 src/app/editor/components/unsplash/unsplash-picker-v2.component.ts diff --git a/server/integrations/unsplash.routes.mjs b/server/integrations/unsplash.routes.mjs index 2c7de2c..430262f 100644 --- a/server/integrations/unsplash.routes.mjs +++ b/server/integrations/unsplash.routes.mjs @@ -12,14 +12,18 @@ router.get('/search', async (req, res) => { } const q = String(req.query.q || '').trim(); const perPage = Math.min(50, Math.max(1, Number(req.query.perPage || 24))); + const page = Math.max(1, Number(req.query.page || 1)); if (!q) return res.json({ results: [] }); const url = new URL('https://api.unsplash.com/search/photos'); url.searchParams.set('query', q); url.searchParams.set('per_page', String(perPage)); + url.searchParams.set('page', String(page)); url.searchParams.set('client_id', ACCESS_KEY); - // Prefer landscape/small for editor usage - url.searchParams.set('orientation', 'landscape'); + // Force portrait orientation for better display in editor + const orientation = String(req.query.orientation || 'portrait'); + url.searchParams.set('orientation', orientation); + console.log('[Unsplash] Search with orientation:', orientation); const upstream = await fetch(url.toString(), { headers: { 'Accept-Version': 'v1' } }); if (!upstream.ok) { @@ -35,6 +39,7 @@ router.get('/search', async (req, res) => { links: r.links, user: r.user ? { name: r.user.name } : undefined, })) : []; + console.log(`[Unsplash] Search returned ${results.length} results for orientation="${orientation}"`); return res.json({ results }); } catch (e) { console.error('[Unsplash] proxy error', e); @@ -42,4 +47,41 @@ router.get('/search', async (req, res) => { } }); +router.get('/random', async (req, res) => { + try { + const ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY; + if (!ACCESS_KEY) { + return res.status(501).json({ error: 'unsplash_disabled' }); + } + const perPage = Math.min(50, Math.max(1, Number(req.query.perPage || 24))); + const orientation = String(req.query.orientation || 'portrait'); + console.log(`[Unsplash] Random request with orientation="${orientation}", count=${perPage}`); + + const url = new URL('https://api.unsplash.com/photos/random'); + url.searchParams.set('count', String(perPage)); + url.searchParams.set('client_id', ACCESS_KEY); + url.searchParams.set('orientation', orientation); + + const upstream = await fetch(url.toString(), { headers: { 'Accept-Version': 'v1' } }); + if (!upstream.ok) { + const text = await upstream.text().catch(() => ''); + return res.status(502).json({ error: 'unsplash_upstream_error', status: upstream.status, message: text }); + } + const json = await upstream.json(); + const arr = Array.isArray(json) ? json : (json ? [json] : []); + const results = arr.map((r) => ({ + id: r.id, + alt_description: r.alt_description || null, + urls: r.urls, + links: r.links, + user: r.user ? { name: r.user.name } : undefined, + })); + console.log(`[Unsplash] Random returned ${results.length} results for orientation="${orientation}"`); + return res.json({ results }); + } catch (e) { + console.error('[Unsplash] random proxy error', e); + return res.status(500).json({ error: 'internal_error' }); + } +}); + export default router; diff --git a/src/app/editor/components/block/block-inline-toolbar.component.ts b/src/app/editor/components/block/block-inline-toolbar.component.ts index f34ed11..eedef1e 100644 --- a/src/app/editor/components/block/block-inline-toolbar.component.ts +++ b/src/app/editor/components/block/block-inline-toolbar.component.ts @@ -2,7 +2,7 @@ import { Component, Output, EventEmitter, Input, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; export interface InlineToolbarAction { - type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'new-page' | 'heading-2' | 'more' | 'drag' | 'menu'; + type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'link' | 'heading-2' | 'more' | 'drag' | 'menu'; } @Component({ @@ -177,10 +177,10 @@ export interface InlineToolbarAction { - + + + + +
+ + +
+ +
+
+ + + + {{ error }} +
+
+ + + + {{ notice }} +
+
+ + {{ loading && results.length === 0 ? 'Loading images…' : 'Loading more…' }} +
+
+
+ +
+ +
+ {{ results.length }} image{{ results.length !== 1 ? 's' : '' }} + + Powered by Unsplash + +
+ + + `, + styles: [` + .custom-scrollbar::-webkit-scrollbar { + width: 8px; + } + .custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + .custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.4); + border-radius: 4px; + } + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.6); + } + `] +}) +export class UnsplashPickerV2Component implements OnDestroy { + open = signal(false); + query = ''; + results: UnsplashImage[] = []; + error = ''; + notice = ''; + loading = false; + page = 1; + hasMore = true; + private mode: 'random' | 'search' = 'random'; + private onSelect: ((url: string) => void) | null = null; + private listener: any; + private abortController: AbortController | null = null; + private scrollDebounceTimer: any = null; + private searchDebounceTimer: any = null; + + constructor(private cdr: ChangeDetectorRef, private zone: NgZone) { + this.listener = (ev: CustomEvent) => { + // Important: run inside Angular zone so that change detection triggers + this.zone.run(() => { + this.onSelect = (ev.detail?.callback as (url: string) => void) || null; + this.reset(); + this.open.set(true); + console.log('[Unsplash V2] Picker opened, loading initial random images...'); + // Load an initial batch of random portrait images + void this.loadRandom(false); + }); + }; + window.addEventListener('nimbus-open-unsplash', this.listener as any); + } + + ngOnDestroy(): void { + window.removeEventListener('nimbus-open-unsplash', this.listener as any); + this.cleanup(); + } + + private cleanup(): void { + if (this.scrollDebounceTimer) { + clearTimeout(this.scrollDebounceTimer); + this.scrollDebounceTimer = null; + } + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = null; + } + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + private reset(): void { + this.cleanup(); + this.results = []; + this.error = ''; + this.notice = ''; + this.query = ''; + this.page = 1; + this.hasMore = true; + this.loading = false; + this.mode = 'random'; + } + + async search(append: boolean = false): Promise { + this.error = ''; + this.notice = ''; + const q = (this.query || '').trim(); + + // Empty query: fall back to random images + if (!q) { + this.mode = 'random'; + this.page = 1; + this.hasMore = true; + await this.loadRandom(false); + return; + } + + this.mode = 'search'; + if (!append) { + this.results = []; + this.page = 1; + this.hasMore = true; + } + + if (this.loading) return; + if (!this.hasMore && append) return; + + console.log(`[Unsplash V2] Searching for "${q}" (page ${this.page}, append=${append})`); + + // Cancel any ongoing request + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + + this.loading = true; + this.cdr.detectChanges(); + try { + const url = `/api/integrations/unsplash/search?q=${encodeURIComponent(q)}&perPage=24&page=${this.page}&orientation=portrait`; + const res = await fetch(url, { signal: this.abortController.signal }); + + if (res.status === 501) { + const data = await res.json().catch(() => ({})); + console.error('[Unsplash V2] API key not configured:', data); + this.notice = '⚠️ Unsplash API key not configured. Please set UNSPLASH_ACCESS_KEY in your server environment.'; + this.loading = false; + return; + } + + if (res.status === 502) { + const data = await res.json().catch(() => ({})); + console.error('[Unsplash V2] Upstream API error:', data); + this.error = `Unsplash API error (${data.status || 'unknown'}). Please try again later.`; + this.loading = false; + return; + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + console.error('[Unsplash V2] Search failed:', res.status, text); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + + const data = await res.json(); + const newResults = Array.isArray(data?.results) ? data.results : []; + console.log(`[Unsplash V2] Received ${newResults.length} search results`); + + this.results = append ? [...this.results, ...newResults] : newResults; + if (!newResults.length) { + this.hasMore = false; + if (!this.results.length) { + this.notice = `No images found for "${q}". Try different keywords.`; + } + } else { + this.page += 1; + } + } catch (e: any) { + if (e.name === 'AbortError') { + console.log('[Unsplash V2] Search request aborted'); + return; // Don't show error for aborted requests + } + console.error('[Unsplash V2] Search error:', e); + if (e.name === 'TypeError' && e.message.includes('fetch')) { + this.error = 'Network error. Please check your internet connection.'; + } else { + this.error = `Search failed: ${e.message || 'Unknown error'}. Please try again.`; + } + } finally { + this.loading = false; + // Force change detection so results appear without needing an extra click + this.cdr.detectChanges(); + } + } + + async loadRandom(append: boolean = false): Promise { + this.error = ''; + if (!append) { + this.notice = ''; + this.results = []; + } + if (this.loading) return; + + console.log(`[Unsplash V2] Loading random images (append=${append})`); + + // Cancel any ongoing request + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + + this.loading = true; + this.mode = 'random'; + try { + const url = '/api/integrations/unsplash/random?perPage=24&orientation=portrait'; + const res = await fetch(url, { signal: this.abortController.signal }); + + if (res.status === 501) { + const data = await res.json().catch(() => ({})); + console.error('[Unsplash V2] API key not configured:', data); + this.notice = '⚠️ Unsplash API key not configured. Please set UNSPLASH_ACCESS_KEY in your server environment.'; + this.loading = false; + return; + } + + if (res.status === 502) { + const data = await res.json().catch(() => ({})); + console.error('[Unsplash V2] Upstream API error:', data); + this.error = `Unsplash API error (${data.status || 'unknown'}). Please try again later.`; + this.loading = false; + return; + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + console.error('[Unsplash V2] Random load failed:', res.status, text); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + + const data = await res.json(); + const newResults = Array.isArray(data?.results) ? data.results : []; + console.log(`[Unsplash V2] Received ${newResults.length} random images`); + + this.results = append ? [...this.results, ...newResults] : newResults; + if (!this.results.length) { + this.notice = 'No images available. Please try again later.'; + } + } catch (e: any) { + if (e.name === 'AbortError') { + console.log('[Unsplash V2] Random request aborted'); + return; // Don't show error for aborted requests + } + console.error('[Unsplash V2] Random load error:', e); + if (e.name === 'TypeError' && e.message.includes('fetch')) { + this.error = 'Network error. Please check your internet connection.'; + } else { + this.error = `Failed to load images: ${e.message || 'Unknown error'}. Please try again.`; + } + } finally { + this.loading = false; + // Ensure UI updates after async random / lazy-loaded images + this.cdr.detectChanges(); + } + } + + async loadMore(): Promise { + if (this.loading) return; + if (this.mode === 'search') { + if (!this.hasMore) return; + await this.search(true); + } else { + await this.loadRandom(true); + } + } + + onScroll(event: Event): void { + const el = event.target as HTMLElement | null; + if (!el) return; + const threshold = 200; // px from bottom + const distance = el.scrollHeight - (el.scrollTop + el.clientHeight); + console.log('[Unsplash V2] Scroll event', { + scrollTop: el.scrollTop, + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + distance, + }); + if (distance <= threshold) { + console.log('[Unsplash V2] Scroll threshold reached, loading more...'); + void this.loadMore(); + } + } + + select(img: UnsplashImage): void { + const url = img?.urls?.regular || img?.urls?.full || img?.urls?.small; + if (url && this.onSelect) this.onSelect(url); + this.close(); + } + + onSearchInput(): void { + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + } + this.searchDebounceTimer = setTimeout(() => { + const query = (this.query || '').trim(); + if (!query) { + console.log('[Unsplash V2] Input cleared, loading random images.'); + this.mode = 'random'; + this.page = 1; + this.hasMore = true; + void this.loadRandom(false); + return; + } + console.log('[Unsplash V2] Debounced input search triggered.'); + void this.search(false); + }, 400); + } + + onSearchEnter(): void { + console.log('[Unsplash V2] Search triggered by Enter key'); + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = null; + } + void this.search(false); + } + + onSearchClick(): void { + console.log('[Unsplash V2] Search triggered by button click'); + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = null; + } + void this.search(false); + } + + close(): void { + console.log('[Unsplash V2] Picker closing...'); + this.open.set(false); + // Don't reset immediately to keep results visible during close animation + // The reset will happen on next open + } +} diff --git a/src/app/editor/components/unsplash/unsplash-picker.component.ts b/src/app/editor/components/unsplash/unsplash-picker.component.ts index 19d6624..53e8e10 100644 --- a/src/app/editor/components/unsplash/unsplash-picker.component.ts +++ b/src/app/editor/components/unsplash/unsplash-picker.component.ts @@ -17,26 +17,70 @@ interface UnsplashImage { template: `
-
+

Search image

- - + +
-
{{ error }}
-
{{ notice }}
-
-
+ +
+ {{ results.length }} image{{ results.length !== 1 ? 's' : '' }} + + Powered by Unsplash + +
`, + styles: [` + .custom-scrollbar::-webkit-scrollbar { + width: 8px; + } + .custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + .custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.4); + border-radius: 4px; + } + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.6); + } + `] }) export class UnsplashPickerComponent implements OnDestroy { open = signal(false); @@ -44,51 +88,292 @@ export class UnsplashPickerComponent implements OnDestroy { results: UnsplashImage[] = []; error = ''; notice = ''; + loading = false; + page = 1; + hasMore = true; + private mode: 'random' | 'search' = 'random'; private onSelect: ((url: string) => void) | null = null; private listener: any; + private abortController: AbortController | null = null; + private scrollDebounceTimer: any = null; + private searchDebounceTimer: any = null; constructor() { this.listener = (ev: CustomEvent) => { this.onSelect = (ev.detail?.callback as (url: string) => void) || null; - this.error = ''; - this.notice = ''; - this.results = []; - this.query = ''; + this.reset(); this.open.set(true); + console.log('[Unsplash] Picker opened, loading initial random images...'); + // Load an initial batch of random portrait images + void this.loadRandom(false); }; window.addEventListener('nimbus-open-unsplash', this.listener as any); } ngOnDestroy(): void { window.removeEventListener('nimbus-open-unsplash', this.listener as any); + this.cleanup(); } - - async search(): Promise { + + private cleanup(): void { + if (this.scrollDebounceTimer) { + clearTimeout(this.scrollDebounceTimer); + this.scrollDebounceTimer = null; + } + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = null; + } + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + private reset(): void { + this.cleanup(); + this.results = []; + this.error = ''; + this.notice = ''; + this.query = ''; + this.page = 1; + this.hasMore = true; + this.loading = false; + this.mode = 'random'; + } + + async search(append: boolean = false): Promise { this.error = ''; this.notice = ''; - this.results = []; const q = (this.query || '').trim(); - if (!q) return; + + // Empty query: fall back to random images + if (!q) { + this.mode = 'random'; + this.page = 1; + this.hasMore = true; + await this.loadRandom(false); + return; + } + + this.mode = 'search'; + if (!append) { + this.results = []; + this.page = 1; + this.hasMore = true; + } + + if (this.loading) return; + if (!this.hasMore && append) return; + + console.log(`[Unsplash] Searching for "${q}" (page ${this.page}, append=${append})`); + + // Cancel any ongoing request + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + + this.loading = true; try { - const res = await fetch(`/api/integrations/unsplash/search?q=${encodeURIComponent(q)}&perPage=24`); + const url = `/api/integrations/unsplash/search?q=${encodeURIComponent(q)}&perPage=24&page=${this.page}&orientation=portrait`; + const res = await fetch(url, { signal: this.abortController.signal }); + if (res.status === 501) { - this.notice = 'Unsplash access key missing. Set UNSPLASH_ACCESS_KEY in server environment to enable search.'; + const data = await res.json().catch(() => ({})); + console.error('[Unsplash] API key not configured:', data); + this.notice = '⚠️ Unsplash API key not configured. Please set UNSPLASH_ACCESS_KEY in your server environment.'; + this.loading = false; return; } - if (!res.ok) throw new Error(`HTTP ${res.status}`); + + if (res.status === 502) { + const data = await res.json().catch(() => ({})); + console.error('[Unsplash] Upstream API error:', data); + this.error = `Unsplash API error (${data.status || 'unknown'}). Please try again later.`; + this.loading = false; + return; + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + console.error('[Unsplash] Search failed:', res.status, text); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + const data = await res.json(); - this.results = Array.isArray(data?.results) ? data.results : []; - if (!this.results.length) this.notice = 'No results.'; + const newResults = Array.isArray(data?.results) ? data.results : []; + console.log(`[Unsplash] Received ${newResults.length} search results`); + + this.results = append ? [...this.results, ...newResults] : newResults; + if (!newResults.length) { + this.hasMore = false; + if (!this.results.length) { + this.notice = `No images found for "${q}". Try different keywords.`; + } + } else { + this.page += 1; + } } catch (e: any) { - this.error = 'Search failed. Please try again.'; + if (e.name === 'AbortError') { + console.log('[Unsplash] Search request aborted'); + return; // Don't show error for aborted requests + } + console.error('[Unsplash] Search error:', e); + if (e.name === 'TypeError' && e.message.includes('fetch')) { + this.error = 'Network error. Please check your internet connection.'; + } else { + this.error = `Search failed: ${e.message || 'Unknown error'}. Please try again.`; + } + } finally { + this.loading = false; } } + async loadRandom(append: boolean = false): Promise { + this.error = ''; + if (!append) { + this.notice = ''; + this.results = []; + } + if (this.loading) return; + + console.log(`[Unsplash] Loading random images (append=${append})`); + + // Cancel any ongoing request + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + + this.loading = true; + this.mode = 'random'; + try { + const url = '/api/integrations/unsplash/random?perPage=24&orientation=portrait'; + const res = await fetch(url, { signal: this.abortController.signal }); + + if (res.status === 501) { + const data = await res.json().catch(() => ({})); + console.error('[Unsplash] API key not configured:', data); + this.notice = '⚠️ Unsplash API key not configured. Please set UNSPLASH_ACCESS_KEY in your server environment.'; + this.loading = false; + return; + } + + if (res.status === 502) { + const data = await res.json().catch(() => ({})); + console.error('[Unsplash] Upstream API error:', data); + this.error = `Unsplash API error (${data.status || 'unknown'}). Please try again later.`; + this.loading = false; + return; + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + console.error('[Unsplash] Random load failed:', res.status, text); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + + const data = await res.json(); + const newResults = Array.isArray(data?.results) ? data.results : []; + console.log(`[Unsplash] Received ${newResults.length} random images`); + + this.results = append ? [...this.results, ...newResults] : newResults; + if (!this.results.length) { + this.notice = 'No images available. Please try again later.'; + } + } catch (e: any) { + if (e.name === 'AbortError') { + console.log('[Unsplash] Random request aborted'); + return; // Don't show error for aborted requests + } + console.error('[Unsplash] Random load error:', e); + if (e.name === 'TypeError' && e.message.includes('fetch')) { + this.error = 'Network error. Please check your internet connection.'; + } else { + this.error = `Failed to load images: ${e.message || 'Unknown error'}. Please try again.`; + } + } finally { + this.loading = false; + } + } + + async loadMore(): Promise { + if (this.loading) return; + if (this.mode === 'search') { + if (!this.hasMore) return; + await this.search(true); + } else { + await this.loadRandom(true); + } + } + + onScroll(event: Event): void { + // Debounce scroll events to avoid too many loadMore calls + if (this.scrollDebounceTimer) { + clearTimeout(this.scrollDebounceTimer); + } + + const target = event.target as HTMLElement | null; + if (!target) return; + this.scrollDebounceTimer = setTimeout(() => { + const el = target; + if (!el) return; + const threshold = 200; // px from bottom + if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) { + console.log('[Unsplash] Scroll threshold reached, loading more...'); + void this.loadMore(); + } + }, 150); // 150ms debounce + } + select(img: UnsplashImage): void { const url = img?.urls?.regular || img?.urls?.full || img?.urls?.small; if (url && this.onSelect) this.onSelect(url); this.close(); } - close(): void { this.open.set(false); } + onSearchInput(): void { + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + } + this.searchDebounceTimer = setTimeout(() => { + const query = (this.query || '').trim(); + if (!query) { + console.log('[Unsplash] Input cleared, loading random images.'); + this.mode = 'random'; + this.page = 1; + this.hasMore = true; + void this.loadRandom(false); + return; + } + console.log('[Unsplash] Debounced input search triggered.'); + void this.search(false); + }, 400); + } + + onSearchEnter(): void { + console.log('[Unsplash] Search triggered by Enter key'); + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = null; + } + void this.search(false); + } + + onSearchClick(): void { + console.log('[Unsplash] Search triggered by button click'); + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = null; + } + void this.search(false); + } + + close(): void { + console.log('[Unsplash] Picker closing...'); + this.open.set(false); + // Don't reset immediately to keep results visible during close animation + // The reset will happen on next open + } } diff --git a/src/app/editor/services/palette.service.ts b/src/app/editor/services/palette.service.ts index ad8efc5..48d0ffd 100644 --- a/src/app/editor/services/palette.service.ts +++ b/src/app/editor/services/palette.service.ts @@ -49,6 +49,53 @@ export class PaletteService { * Used by all menus to create/insert blocks consistently. */ async applySelection(item: PaletteItem): Promise { + // Special handling for Unsplash: open the global picker modal and insert an image block + if (item.id === 'unsplash') { + this.close(); + try { + const ev = new CustomEvent('nimbus-open-unsplash', { + detail: { + callback: async (imageUrl: string) => { + if (!imageUrl) return; + const blocks = this.documentService.blocks(); + const activeId = this.selectionService.getActive(); + let replaceBlockId: string | null = null; + let insertAfterId: string | null = null; + + if (activeId) { + const idx = blocks.findIndex(b => b.id === activeId); + if (idx >= 0) { + const blk: any = blocks[idx]; + if (blk.type === 'paragraph' && (!blk.props?.text || String(blk.props.text).trim() === '')) { + replaceBlockId = blk.id; + } else { + insertAfterId = blk.id; + } + } + } + + if (replaceBlockId) { + this.documentService.convertBlock(replaceBlockId, 'image' as any, { src: imageUrl, alt: '' }); + this.selectionService.setActive(replaceBlockId); + } else { + const imgBlock = this.documentService.createBlock('image' as any, { src: imageUrl, alt: '' }); + if (insertAfterId) { + this.documentService.insertBlock(insertAfterId, imgBlock); + } else { + this.documentService.appendBlock(imgBlock); + } + this.selectionService.setActive(imgBlock.id); + } + } + } + }); + window.dispatchEvent(ev); + } catch { + // Swallow errors: if the picker is not mounted, failing silently is acceptable + } + return; + } + // Special handling for File: open multi-picker and create N blocks if (item.type === 'file' || item.id === 'file') { const files = await this.filePicker.pick({ multiple: true, accept: '*/*' }); diff --git a/vault/.test/Test 1 Markdown copy.md b/vault/.test/Test 1 Markdown copy.md index d367547..c58bd7c 100644 --- a/vault/.test/Test 1 Markdown copy.md +++ b/vault/.test/Test 1 Markdown copy.md @@ -19,9 +19,6 @@ color: "#A855F7" catégorie: "" description: "Allo ceci est un tests toto Test 1 Markdown Titres Niveau 1 #tag1 #tag2 #test..." --- -Allo ceci est un tests -toto - # Test 1 Markdown ## Titres diff --git a/vault/tests/nimbus-editor-snapshot.md b/vault/tests/nimbus-editor-snapshot.md index 3c187b8..22327a1 100644 --- a/vault/tests/nimbus-editor-snapshot.md +++ b/vault/tests/nimbus-editor-snapshot.md @@ -8,22 +8,10 @@ documentModelFormat: "block-model-v1" { "id": "block_1763149113471_461xyut80", "title": "Page Tests", - "blocks": [ - { - "id": "block_1763391929543_lu1lzz0yz", - "type": "paragraph", - "props": { - "text": "" - }, - "meta": { - "createdAt": "2025-11-17T15:05:29.543Z", - "updatedAt": "2025-11-17T15:05:29.543Z" - } - } - ], + "blocks": [], "meta": { "createdAt": "2025-11-14T19:38:33.471Z", - "updatedAt": "2025-11-17T15:05:29.543Z" + "updatedAt": "2025-11-17T20:27:14.570Z" } } ```