```
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
This commit is contained in:
parent
5e8cddf92e
commit
332f586d7b
@ -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;
|
||||
|
||||
@ -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 {
|
||||
</button>
|
||||
|
||||
<!-- Link/New Page -->
|
||||
<button *ngIf="!actions || actions.includes('new-page')"
|
||||
<button *ngIf="!actions || actions.includes('link')"
|
||||
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
|
||||
title="Link to page"
|
||||
(click)="onAction('new-page')"
|
||||
(click)="onAction('link')"
|
||||
type="button"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
||||
@ -129,15 +129,23 @@ export class LinkBlockComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Auto-open edit modal when a new link block is created without URL.
|
||||
// Auto-open edit modal when a new link block is created.
|
||||
// This is triggered both when inserting a fresh link block and when converting
|
||||
// from another block type, so the user can confirm text and enter the URL.
|
||||
setTimeout(() => {
|
||||
if (this.autoEditInitialized) return;
|
||||
if (!this.props?.url) {
|
||||
this.autoEditInitialized = true;
|
||||
try {
|
||||
console.debug('[LinkBlock] ngOnInit auto-open check', {
|
||||
id: this.block?.id,
|
||||
text: this.props?.text,
|
||||
url: this.props?.url
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// Open the editor whenever the block is created via palette / toolbar.
|
||||
// If a URL already exists (e.g. imported content), the user can still confirm it.
|
||||
this.startEdit();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@ -181,6 +189,13 @@ export class LinkBlockComponent implements OnInit {
|
||||
this.editText = this.props.text || '';
|
||||
this.editUrl = this.props.url || '';
|
||||
this.editing.set(true);
|
||||
try {
|
||||
console.debug('[LinkBlock] startEdit()', {
|
||||
id: this.block?.id,
|
||||
editText: this.editText,
|
||||
editUrl: this.editUrl
|
||||
});
|
||||
} catch {}
|
||||
// Focus the text input on next tick for better keyboard UX
|
||||
setTimeout(() => {
|
||||
try {
|
||||
|
||||
@ -182,6 +182,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
|
||||
'table': 'table',
|
||||
'image': 'image',
|
||||
'file': 'file',
|
||||
'link': 'link',
|
||||
'heading-2': 'heading-2',
|
||||
};
|
||||
const id = map[type];
|
||||
|
||||
@ -11,7 +11,7 @@ import { BlockMenuComponent } from '../palette/block-menu.component';
|
||||
import { BlockMenuAction } from '../block/block-initial-menu.component';
|
||||
import { TocButtonComponent } from '../toc/toc-button.component';
|
||||
import { TocPanelComponent } from '../toc/toc-panel.component';
|
||||
import { UnsplashPickerComponent } from '../unsplash/unsplash-picker.component';
|
||||
import { UnsplashPickerV2Component } from '../unsplash/unsplash-picker-v2.component';
|
||||
import { DragDropService } from '../../services/drag-drop.service';
|
||||
import { DragDropFilesDirective } from '../../../blocks/file/directives/drag-drop-files.directive';
|
||||
import { FilePickerService } from '../../../blocks/file/services/file-picker.service';
|
||||
@ -21,7 +21,7 @@ import { PaletteItem, PALETTE_ITEMS } from '../../core/constants/palette-items';
|
||||
@Component({
|
||||
selector: 'app-editor-shell',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerComponent, DragDropFilesDirective],
|
||||
imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerV2Component, DragDropFilesDirective],
|
||||
template: `
|
||||
<div class="grid h-full w-full grid-rows-[auto,1fr] grid-cols-[1fr,auto] overflow-hidden">
|
||||
<div class="row-[1] col-[1/3] px-8 py-4 bg-card dark:bg-main border-b border-border" (click)="onShellClick()">
|
||||
@ -120,8 +120,8 @@ import { PaletteItem, PALETTE_ITEMS } from '../../core/constants/palette-items';
|
||||
|
||||
<!-- Block Menu -->
|
||||
<app-block-menu (itemSelected)="onPaletteItemSelected($event)" />
|
||||
<!-- Unsplash Picker Modal -->
|
||||
<app-unsplash-picker />
|
||||
<!-- Unsplash Picker Modal (v2) -->
|
||||
<app-unsplash-picker-v2 />
|
||||
|
||||
`,
|
||||
styles: [`
|
||||
@ -295,6 +295,7 @@ export class EditorShellComponent implements AfterViewInit {
|
||||
'image': 'image',
|
||||
'file': 'file',
|
||||
'heading-2': 'heading-2',
|
||||
'link': 'link',
|
||||
// 'new-page' and 'use-ai' are placeholders and not palette-backed
|
||||
};
|
||||
const id = idMap[action];
|
||||
|
||||
@ -2,7 +2,7 @@ import { Component, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
export interface ToolbarAction {
|
||||
type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'new-page' | 'heading-2' | 'more';
|
||||
type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'new-page' | 'heading-2' | 'link' | 'more';
|
||||
label: string;
|
||||
}
|
||||
|
||||
@ -106,6 +106,18 @@ export interface ToolbarAction {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Link -->
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
|
||||
title="Link"
|
||||
(click)="onAction('link')"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.5 1.5" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.5-1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- New Page -->
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
|
||||
|
||||
@ -0,0 +1,385 @@
|
||||
import { Component, OnDestroy, signal, ChangeDetectorRef, NgZone } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
interface UnsplashImage {
|
||||
id: string;
|
||||
alt_description: string | null;
|
||||
urls: { thumb: string; small: string; regular: string; full: string };
|
||||
links?: { html?: string };
|
||||
user?: { name?: string };
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-unsplash-picker-v2',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div *ngIf="open()" class="fixed inset-0 z-[1000] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/50" (click)="close()"></div>
|
||||
<div class="relative w-[min(720px,90vw)] max-h-[80vh] bg-surface1 text-main dark:text-neutral-100 rounded-xl shadow-2xl border border-border dark:border-gray-700 overflow-hidden flex flex-col">
|
||||
<div class="px-4 py-3 border-b border-border dark:border-gray-700 flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold flex-1">Search image (V2)</h3>
|
||||
<button class="btn btn-xs" (click)="close()">Close</button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center gap-2">
|
||||
<input class="input input-bordered flex-1" placeholder="Search portrait images..." [(ngModel)]="query" (keyup.enter)="onSearchEnter()" (input)="onSearchInput()" />
|
||||
<button class="btn btn-primary btn-sm" (click)="onSearchClick()" [disabled]="loading">Search</button>
|
||||
</div>
|
||||
<!-- Status Messages -->
|
||||
<div class="px-3 pb-3" *ngIf="error || notice || loading">
|
||||
<div class="flex items-start gap-2 text-xs" *ngIf="error">
|
||||
<svg class="w-4 h-4 text-error flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-error">{{ error }}</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 text-xs" *ngIf="notice && !error">
|
||||
<svg class="w-4 h-4 text-warning flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-text-muted">{{ notice }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs" *ngIf="loading">
|
||||
<span class="inline-block w-3 h-3 rounded-full border-2 border-primary border-t-transparent animate-spin flex-shrink-0"></span>
|
||||
<span class="text-text-muted">{{ loading && results.length === 0 ? 'Loading images…' : 'Loading more…' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 pt-0 overflow-auto custom-scrollbar grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 min-h-[200px]" (scroll)="onScroll($event)">
|
||||
<button *ngFor="let img of results" class="relative rounded-md overflow-hidden border border-border dark:border-gray-700 hover:border-primary hover:shadow-md transition-all focus:outline-none focus:ring-2 focus:ring-primary group"
|
||||
title="Click to insert" (click)="select(img)">
|
||||
<div class="relative w-full aspect-[3/4] min-h-[180px] bg-surface2">
|
||||
<img [src]="img.urls.thumb"
|
||||
[alt]="img.alt_description || ''"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-2 border-t border-border dark:border-gray-700 bg-surface2/50 flex items-center justify-between text-xs text-text-muted">
|
||||
<span>{{ results.length }} image{{ results.length !== 1 ? 's' : '' }}</span>
|
||||
<a href="https://unsplash.com" target="_blank" rel="noopener noreferrer" class="hover:text-text-main transition-colors">
|
||||
Powered by <span class="font-semibold">Unsplash</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -17,26 +17,70 @@ interface UnsplashImage {
|
||||
template: `
|
||||
<div *ngIf="open()" class="fixed inset-0 z-[1000] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/50" (click)="close()"></div>
|
||||
<div class="relative w-[min(900px,95vw)] max-h-[85vh] bg-surface1 text-main dark:text-neutral-100 rounded-xl shadow-2xl border border-border dark:border-gray-700 overflow-hidden flex flex-col">
|
||||
<div class="relative w-[min(720px,90vw)] max-h-[80vh] bg-surface1 text-main dark:text-neutral-100 rounded-xl shadow-2xl border border-border dark:border-gray-700 overflow-hidden flex flex-col">
|
||||
<div class="px-4 py-3 border-b border-border dark:border-gray-700 flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold flex-1">Search image</h3>
|
||||
<button class="btn btn-xs" (click)="close()">Close</button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center gap-2">
|
||||
<input class="input input-bordered flex-1" placeholder="Search for an image" [(ngModel)]="query" (keyup.enter)="search()" />
|
||||
<button class="btn btn-primary btn-sm" (click)="search()">Search</button>
|
||||
<input class="input input-bordered flex-1" placeholder="Search portrait images..." [(ngModel)]="query" (keyup.enter)="onSearchEnter()" (input)="onSearchInput()" />
|
||||
<button class="btn btn-primary btn-sm" (click)="onSearchClick()" [disabled]="loading">Search</button>
|
||||
</div>
|
||||
<div class="px-3 pb-3 text-xs text-text-muted" *ngIf="error">{{ error }}</div>
|
||||
<div class="px-3 pb-3 text-xs text-text-muted" *ngIf="notice">{{ notice }}</div>
|
||||
<div class="p-3 pt-0 overflow-auto grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 min-h-[200px]">
|
||||
<button *ngFor="let img of results" class="relative rounded-md overflow-hidden border border-border dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
<!-- Status Messages -->
|
||||
<div class="px-3 pb-3" *ngIf="error || notice || loading">
|
||||
<div class="flex items-start gap-2 text-xs" *ngIf="error">
|
||||
<svg class="w-4 h-4 text-error flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-error">{{ error }}</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 text-xs" *ngIf="notice && !error">
|
||||
<svg class="w-4 h-4 text-warning flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-text-muted">{{ notice }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs" *ngIf="loading">
|
||||
<span class="inline-block w-3 h-3 rounded-full border-2 border-primary border-t-transparent animate-spin flex-shrink-0"></span>
|
||||
<span class="text-text-muted">{{ loading && results.length === 0 ? 'Loading images…' : 'Loading more…' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 pt-0 overflow-auto custom-scrollbar grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 min-h-[200px]" (scroll)="onScroll($event)">
|
||||
<button *ngFor="let img of results" class="relative rounded-md overflow-hidden border border-border dark:border-gray-700 hover:border-primary hover:shadow-md transition-all focus:outline-none focus:ring-2 focus:ring-primary group"
|
||||
title="Click to insert" (click)="select(img)">
|
||||
<img [src]="img.urls.small" [alt]="img.alt_description || ''" class="block w-full h-full object-cover" />
|
||||
<div class="relative w-full aspect-[3/4] min-h-[180px] bg-surface2">
|
||||
<img [src]="img.urls.thumb"
|
||||
[alt]="img.alt_description || ''"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-2 border-t border-border dark:border-gray-700 bg-surface2/50 flex items-center justify-between text-xs text-text-muted">
|
||||
<span>{{ results.length }} image{{ results.length !== 1 ? 's' : '' }}</span>
|
||||
<a href="https://unsplash.com" target="_blank" rel="noopener noreferrer" class="hover:text-text-main transition-colors">
|
||||
Powered by <span class="font-semibold">Unsplash</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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,44 +88,243 @@ 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<void> {
|
||||
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<void> {
|
||||
this.error = '';
|
||||
this.notice = '';
|
||||
this.results = [];
|
||||
const q = (this.query || '').trim();
|
||||
if (!q) return;
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/unsplash/search?q=${encodeURIComponent(q)}&perPage=24`);
|
||||
if (res.status === 501) {
|
||||
this.notice = 'Unsplash access key missing. Set UNSPLASH_ACCESS_KEY in server environment to enable search.';
|
||||
|
||||
// Empty query: fall back to random images
|
||||
if (!q) {
|
||||
this.mode = 'random';
|
||||
this.page = 1;
|
||||
this.hasMore = true;
|
||||
await this.loadRandom(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this.results = Array.isArray(data?.results) ? data.results : [];
|
||||
if (!this.results.length) this.notice = 'No results.';
|
||||
} catch (e: any) {
|
||||
this.error = 'Search failed. Please try again.';
|
||||
|
||||
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 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] 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] 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] 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] 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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
@ -90,5 +333,47 @@ export class UnsplashPickerComponent implements OnDestroy {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,53 @@ export class PaletteService {
|
||||
* Used by all menus to create/insert blocks consistently.
|
||||
*/
|
||||
async applySelection(item: PaletteItem): Promise<void> {
|
||||
// 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: '*/*' });
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user