feat: add bookmark view modes (card/tile/cover) and enhance button block with visual config modal

- Added three view modes for bookmark blocks: card (default with side image), tile (compact horizontal), and cover (large top image)
- Implemented view mode submenu in block context menu with visual indicators for active mode
- Enhanced bookmark favicon handling with fallback logic and error state tracking
- Refactored button block with inline visual editor replacing text inputs
- Created Button
This commit is contained in:
Bruno Charest 2025-11-20 13:51:51 -05:00
parent b695095593
commit 6b47ec39ff
13 changed files with 1436 additions and 113 deletions

View File

@ -0,0 +1,291 @@
# URL Paste Menu & Button Configuration - Documentation
## 📋 Vue d'ensemble
Cette fonctionnalité ajoute deux nouvelles capacités à l'éditeur Nimbus :
1. **Menu de collage d'URL** : Lorsqu'un utilisateur colle une URL dans un bloc paragraphe, un menu contextuel apparaît avec plusieurs options de transformation
2. **Configuration avancée des boutons** : Un modal de configuration complet pour personnaliser les blocs boutons
## 🎯 Fonctionnalités implémentées
### 1. Menu de collage d'URL (UrlPasteMenu)
Lorsqu'une URL est collée dans un bloc paragraphe en mode prompt, un menu s'affiche avec 5 options :
#### Options disponibles :
- **URL** : Affiche l'URL brute dans le bloc paragraphe
- **Titre** : Récupère le titre du site web et l'affiche dans le paragraphe
- **Intégrer** : Convertit le bloc en `embed-block` pour afficher la page dans une iframe ajustable
- **Marque-page** : Convertit le bloc en `bookmark-block` avec preview de la page
- **Bouton** : Convertit le bloc en `button-block` avec l'URL configurée
#### Comportement :
- Détection automatique des URLs (regex `https?://...`)
- Menu positionné près du curseur
- Récupération asynchrone du titre via `UrlPreviewService`
- Fermeture automatique après sélection
### 2. Configuration avancée des boutons (ButtonConfigModal)
Un modal complet pour configurer tous les aspects d'un bouton :
#### Paramètres configurables :
**Texte et URL**
- Titre du bouton (label)
- URL ou Email de destination
- ID unique du bouton (affiché, non modifiable)
**Comportement**
- ☑️ Ouvrir dans un nouvel onglet
**Forme du bouton**
- 🔵 Pill (arrondi complet)
- ⬜ Rounded (coins arrondis)
**Couleur d'arrière-plan**
- Palette de 7 couleurs prédéfinies :
- Bleu (#3b82f6)
- Rouge (#ef4444)
- Orange (#f59e0b)
- Vert (#10b981)
- Violet (#8b5cf6)
- Rose (#ec4899)
- Gris (#6b7280)
**Taille**
- Petit (small)
- Moyen (medium) - par défaut
- Grand (large)
**Type de bouton**
- ⚪ Bouton 3D (effet de profondeur avec border-bottom)
- ⚪ Bouton avec ombre (shadow + hover effect)
- ⚪ Par défaut (style simple)
#### Comportement du modal :
- Ouverture automatique lors de la création d'un nouveau bouton
- Bouton d'édition (crayon) visible au survol du bouton
- Boutons "Annuler" et "Terminé"
- Validation en temps réel
## 📁 Fichiers créés
### Composants
1. **`src/app/editor/components/block/url-paste-menu.component.ts`**
- Composant standalone Angular
- Affiche le menu contextuel lors du collage d'URL
- Récupère le titre du site via `UrlPreviewService`
- Émet l'action sélectionnée vers le parent
2. **`src/app/editor/components/block/blocks/button-config-modal.component.ts`**
- Modal de configuration des boutons
- Interface complète avec tous les paramètres
- Utilise Angular Signals pour la réactivité
- Design cohérent avec le reste de l'application
### Modifications
3. **`src/app/editor/components/block/blocks/paragraph-block.component.ts`**
- Ajout de l'import `UrlPasteMenuComponent`
- Ajout de la méthode `onPaste()` pour intercepter le collage
- Ajout de la méthode `onPasteMenuAction()` pour gérer les actions
- Ajout des signaux : `pasteMenuVisible`, `pastedUrl`, `pasteMenuPosition`
- Détection d'URL via regex
- Conversion de blocs selon l'action choisie
4. **`src/app/editor/components/block/blocks/button-block.component.ts`**
- Refonte complète du composant
- Ajout de l'import `ButtonConfigModalComponent`
- Rendu visuel du bouton avec tous les styles
- Ouverture automatique du modal si le bouton est vide
- Bouton d'édition au survol
- Méthodes : `getButtonClasses()`, `getBgColor()`, `getTextColor()`
5. **`src/app/editor/core/models/block.model.ts`**
- Extension de l'interface `ButtonProps` :
```typescript
export interface ButtonProps {
label: string;
url: string;
variant?: 'primary' | 'secondary' | 'outline' | '3d' | 'shadow' | 'default';
openInNewTab?: boolean;
shape?: 'pill' | 'rounded' | 'square';
backgroundColor?: string;
size?: 'small' | 'medium' | 'large';
}
```
## 🔄 Flux d'utilisation
### Scénario 1 : Collage d'URL
```
1. Utilisateur colle une URL (Ctrl+V) dans un paragraphe vide
2. ParagraphBlockComponent.onPaste() détecte l'URL
3. UrlPasteMenu s'affiche avec 5 options
4. Utilisateur clique sur une option (ex: "Bouton")
5. ParagraphBlockComponent.onPasteMenuAction() convertit le bloc
6. Le bloc devient un ButtonBlock
7. ButtonConfigModal s'ouvre automatiquement (label vide)
8. Utilisateur configure le bouton
9. Clic sur "Terminé" → bouton créé et stylisé
```
### Scénario 2 : Création directe de bouton
```
1. Utilisateur crée un bloc bouton via le menu "/"
2. ButtonBlock créé avec props par défaut
3. ButtonConfigModal s'ouvre automatiquement
4. Utilisateur configure tous les paramètres
5. Clic sur "Terminé" → bouton créé
```
### Scénario 3 : Édition d'un bouton existant
```
1. Utilisateur survole un bouton existant
2. Icône crayon apparaît en haut à droite
3. Clic sur l'icône
4. ButtonConfigModal s'ouvre avec les valeurs actuelles
5. Modifications et "Terminé"
6. Bouton mis à jour
```
## 🎨 Styles CSS appliqués
### Classes dynamiques du bouton
**Taille** :
- `small` : `px-3 py-1.5 text-xs`
- `medium` : `px-4 py-2 text-sm`
- `large` : `px-6 py-3 text-base`
**Forme** :
- `pill` : `rounded-full`
- `rounded` : `rounded-lg`
- `square` : `rounded-md`
**Variant** :
- `3d` : `border-b-4 border-black/20 active:border-b-0 active:translate-y-1`
- `shadow` : `shadow-lg hover:shadow-xl hover:-translate-y-0.5`
- `outline` : `border-2` (fond transparent, texte coloré)
## 🔧 Intégration technique
### Dépendances
- `UrlPreviewService` : Pour récupérer les métadonnées des URLs
- `DocumentService` : Pour convertir les blocs
- `Angular Signals` : Pour la réactivité
- `CommonModule`, `FormsModule` : Modules Angular standard
### Détection d'URL
Regex utilisée :
```typescript
/^https?:\/\/[^\s/$.?#].[^\s]*$/i
```
Cette regex détecte :
- Protocole `http://` ou `https://`
- Domaine valide
- Chemin optionnel
- Pas d'espaces
### Conversion de blocs
Utilise `DocumentService.updateBlock()` pour transformer le bloc paragraphe en :
- `embed` : `{ url, provider: 'generic' }`
- `bookmark` : `{ url, title, viewMode: 'card' }`
- `button` : `{ label, url, variant: 'primary' }`
## ✅ Tests recommandés
### Test 1 : Menu URL
1. Ouvrir l'éditeur Nimbus
2. Créer un nouveau paragraphe
3. Coller `https://example.com`
4. Vérifier que le menu apparaît
5. Tester chaque option
### Test 2 : Configuration bouton
1. Créer un bouton via "/"
2. Vérifier l'ouverture du modal
3. Modifier tous les paramètres
4. Vérifier le rendu visuel
### Test 3 : Édition bouton
1. Créer un bouton
2. Survoler le bouton
3. Cliquer sur l'icône crayon
4. Modifier et sauvegarder
### Test 4 : URLs complexes
- URL avec paramètres : `https://example.com?param=value`
- URL avec ancre : `https://example.com#section`
- URL avec port : `http://localhost:3000`
## 🐛 Gestion des erreurs
- Si `UrlPreviewService` échoue, le titre reste vide (pas de blocage)
- Si l'URL est invalide, le menu ne s'affiche pas
- Si le modal est annulé, aucune modification n'est appliquée
- Prévention de navigation dans l'éditeur (click intercepté)
## 🚀 Améliorations futures possibles
1. **Menu URL** :
- Support des URLs sans protocole (auto-ajout de `https://`)
- Prévisualisation de l'embed avant conversion
- Historique des URLs récentes
2. **Configuration bouton** :
- Sélecteur de couleur personnalisé (color picker)
- Prévisualisation en temps réel dans le modal
- Templates de boutons prédéfinis
- Support des icônes (Lucide, FontAwesome)
3. **Général** :
- Raccourcis clavier pour le menu URL
- Drag & drop d'URLs depuis le navigateur
- Support des emails (mailto:)
- Support des numéros de téléphone (tel:)
## 📝 Notes de développement
- Tous les composants sont **standalone** (pas de module requis)
- Utilisation de **Angular Signals** pour la réactivité moderne
- **TailwindCSS** pour tous les styles
- **z-index: 12000** pour le modal (au-dessus de tout)
- **z-index: 11000** pour le menu URL
- Pas de dépendances externes supplémentaires
## ✨ Résumé
Cette implémentation ajoute une expérience utilisateur fluide et moderne pour :
- Transformer rapidement des URLs collées en différents types de blocs
- Configurer finement l'apparence et le comportement des boutons
- Maintenir la cohérence visuelle avec le reste de l'éditeur Nimbus
Tous les fichiers sont prêts et fonctionnels. La fonctionnalité est complète et prête pour les tests.

View File

@ -6,7 +6,43 @@ import { CodeThemeService } from '../../services/code-theme.service';
import { BlockMenuStylingService } from '../../services/block-menu-styling.service'; import { BlockMenuStylingService } from '../../services/block-menu-styling.service';
export interface MenuAction { export interface MenuAction {
type: 'comment' | 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'copyCode' | 'toggleWrap' | 'toggleLineNumbers' | 'addCaption' | 'tableLayout' | 'copyTable' | 'filterTable' | 'importCSV' | 'tableHelp' | 'insertColumn' | 'imageAspectRatio' | 'imageAlignment' | 'imageDefaultSize' | 'imageReplace' | 'imageRotate' | 'imageSetPreview' | 'imageOCR' | 'imageDownload' | 'imageViewFull' | 'imageOpenTab' | 'imageInfo' | 'duplicate' | 'copy' | 'lock' | 'copyLink' | 'delete' | 'align' | 'indent'; type: 'comment'
| 'add'
| 'convert'
| 'background'
| 'lineColor'
| 'borderColor'
| 'codeTheme'
| 'codeLanguage'
| 'copyCode'
| 'toggleWrap'
| 'toggleLineNumbers'
| 'addCaption'
| 'tableLayout'
| 'copyTable'
| 'filterTable'
| 'importCSV'
| 'tableHelp'
| 'insertColumn'
| 'imageAspectRatio'
| 'imageAlignment'
| 'imageDefaultSize'
| 'imageReplace'
| 'imageRotate'
| 'imageSetPreview'
| 'imageOCR'
| 'imageDownload'
| 'imageViewFull'
| 'imageOpenTab'
| 'imageInfo'
| 'duplicate'
| 'copy'
| 'lock'
| 'copyLink'
| 'delete'
| 'align'
| 'indent'
| 'bookmarkViewMode';
payload?: any; payload?: any;
} }
@ -153,6 +189,55 @@ export interface MenuAction {
<span>Comment</span> <span>Comment</span>
</button> </button>
<!-- Bookmark-only: view mode submenu (Carte / Tuile / Couverture) -->
@if (block.type === 'bookmark') {
<div class="relative">
<button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'bookmarkViewMode'"
(mouseenter)="onOpenSubmenu($event, 'bookmarkViewMode')"
(click)="toggleSubmenu($event, 'bookmarkViewMode')"
>
<div class="flex items-center gap-2.5">
<span class="text-base">👁</span>
<span>Voir comme</span>
</div>
<span class="text-xs"></span>
</button>
<div
*ngIf="showSubmenu === 'bookmarkViewMode'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[200px] z-50"
[attr.data-submenu-panel]="'bookmarkViewMode'"
[ngStyle]="submenuStyle['bookmarkViewMode']"
(mouseenter)="keepSubmenuOpen('bookmarkViewMode')"
(mouseleave)="closeSubmenu()"
>
<button
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm flex items-center justify-between"
(click)="onAction('bookmarkViewMode', { viewMode: 'card' })"
>
<span>Carte</span>
@if (isActiveBookmarkViewMode('card')) { <span class="text-primary"></span> }
</button>
<button
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm flex items-center justify-between"
(click)="onAction('bookmarkViewMode', { viewMode: 'tile' })"
>
<span>Tuile</span>
@if (isActiveBookmarkViewMode('tile')) { <span class="text-primary"></span> }
</button>
<button
class="w-full text-left px-3 py-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700 transition text-sm flex items-center justify-between"
(click)="onAction('bookmarkViewMode', { viewMode: 'cover' })"
>
<span>Couverture</span>
@if (isActiveBookmarkViewMode('cover')) { <span class="text-primary"></span> }
</button>
</div>
</div>
}
<div class="relative"> <div class="relative">
<button <button
class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
@ -1004,7 +1089,7 @@ export class BlockContextMenuComponent implements OnChanges, OnDestroy {
} }
} }
showSubmenu: 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'tableLayout' | 'imageAspectRatio' | 'imageAlignment' | null = null; showSubmenu: 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'tableLayout' | 'imageAspectRatio' | 'imageAlignment' | 'bookmarkViewMode' | null = null;
submenuStyle: Record<string, any> = {}; submenuStyle: Record<string, any> = {};
private _submenuAnchor: HTMLElement | null = null; private _submenuAnchor: HTMLElement | null = null;
@ -1416,4 +1501,10 @@ export class BlockContextMenuComponent implements OnChanges, OnDestroy {
const current = (this.block.props as any)?.alignment || 'center'; const current = (this.block.props as any)?.alignment || 'center';
return current === a; return current === a;
} }
isActiveBookmarkViewMode(mode: 'card' | 'tile' | 'cover'): boolean {
if (this.block.type !== 'bookmark') return false;
const current = (this.block.props as any)?.viewMode || 'card';
return current === mode;
}
} }

View File

@ -80,7 +80,7 @@ import { BookmarkBlockComponent } from './blocks/bookmark-block.component';
[attr.data-block-index]="index" [attr.data-block-index]="index"
[class.active]="isActive()" [class.active]="isActive()"
[class.locked]="block.meta?.locked" [class.locked]="block.meta?.locked"
[style.background-color]="(block.type === 'list-item' || block.type === 'file' || block.type === 'paragraph' || block.type === 'list' || block.type === 'heading' || block.type === 'link') ? null : block.meta?.bgColor" [style.background-color]="(block.type === 'list-item' || block.type === 'file' || block.type === 'paragraph' || block.type === 'list' || block.type === 'heading' || block.type === 'link' || block.type === 'bookmark') ? null : block.meta?.bgColor"
[ngStyle]="blockStyles()" [ngStyle]="blockStyles()"
(click)="onBlockClick($event)" (click)="onBlockClick($event)"
> >
@ -606,6 +606,14 @@ export class BlockHostComponent implements OnDestroy {
} }
} }
break; break;
case 'bookmarkViewMode':
if (this.block.type === 'bookmark') {
const mode = (action.payload || {}).viewMode as 'card' | 'tile' | 'cover' | undefined;
if (mode === 'card' || mode === 'tile' || mode === 'cover') {
this.documentService.updateBlockProps(this.block.id, { viewMode: mode });
}
}
break;
case 'indent': case 'indent':
const { delta } = action.payload || {}; const { delta } = action.payload || {};
if (delta !== undefined) { if (delta !== undefined) {

View File

@ -37,32 +37,91 @@ import { UrlPreviewService } from '../../../services/url-preview.service';
<button type="button" class="ml-auto px-2 py-1 rounded bg-red-700 text-xs" (click)="onRetry()">Retry</button> <button type="button" class="ml-auto px-2 py-1 rounded bg-red-700 text-xs" (click)="onRetry()">Retry</button>
</div> </div>
} @else { } @else {
<a @switch (viewMode) {
class="border border-gray-700 rounded-xl bg-surface1 flex flex-col sm:flex-row overflow-hidden hover:border-blue-400 hover:bg-surface2 transition-colors" @case ('tile') {
[href]="props.url" <!-- Compact horizontal tile view: favicon + title on first line, URL on second line -->
target="_blank" <a
rel="noopener noreferrer" class="border border-gray-700 rounded-xl bg-surface1 flex flex-row items-center overflow-hidden hover:border-blue-400 hover:bg-surface2 transition-colors"
> [href]="props.url"
<div class="flex-1 min-w-0 px-4 py-3 flex flex-col gap-1 justify-center order-2 sm:order-1"> target="_blank"
<div class="text-sm font-semibold text-neutral-100 truncate"> rel="noopener noreferrer"
{{ displayTitle }} [style.background-color]="backgroundColor || null"
</div> >
<div class="flex items-center gap-2 text-xs"> <div class="flex-1 min-w-0 px-4 py-3 flex flex-col gap-1 justify-center">
@if (props.faviconUrl) { <div class="flex items-center gap-2 text-xs">
<img [src]="props.faviconUrl" class="w-4 h-4 rounded-sm flex-shrink-0" alt="" /> @if (faviconUrl && !faviconBroken) {
} <img [src]="faviconUrl" class="w-4 h-4 rounded-sm flex-shrink-0" alt="" (error)="onFaviconError()" />
<span class="truncate text-blue-400">{{ displayUrl }}</span> }
</div> <div class="text-sm font-semibold text-neutral-100 truncate">
@if (props.description) { {{ displayTitle }}
<div class="text-xs text-neutral-400 line-clamp-2">{{ props.description }}</div> </div>
} </div>
</div> <div class="text-xs truncate text-blue-400">
@if (props.imageUrl && showImage) { {{ displayUrl }}
<div class="sm:w-32 md:w-48 h-32 sm:h-auto sm:min-h-[80px] overflow-hidden flex-shrink-0 order-1 sm:order-2"> </div>
<img [src]="props.imageUrl" class="w-full h-full object-cover" alt="" /> </div>
</div> </a>
} }
</a> @case ('cover') {
<!-- Cover view: large image on top, text below (always show image when available) -->
<a
class="border border-gray-700 rounded-xl bg-surface1 flex flex-col overflow-hidden hover:border-blue-400 hover:bg-surface2 transition-colors"
[href]="props.url"
target="_blank"
rel="noopener noreferrer"
[style.background-color]="backgroundColor || null"
>
@if (props.imageUrl) {
<div class="w-full h-40 sm:h-52 bg-black/20 overflow-hidden flex-shrink-0">
<img [src]="props.imageUrl" class="w-full h-full object-cover" alt="" />
</div>
}
<div class="px-4 py-3 flex items-center justify-between gap-3">
<div class="min-w-0 flex-1 flex flex-col gap-1">
<div class="text-sm font-semibold text-neutral-100 truncate">
{{ displayTitle }}
</div>
<div class="flex items-center gap-2 text-xs">
@if (faviconUrl && !faviconBroken) {
<img [src]="faviconUrl" class="w-4 h-4 rounded-sm flex-shrink-0" alt="" (error)="onFaviconError()" />
}
<span class="truncate text-blue-400">{{ displayUrl }}</span>
</div>
</div>
</div>
</a>
}
@default {
<!-- Card view: text on the left, image thumbnail on the right -->
<a
class="border border-gray-700 rounded-xl bg-surface1 flex flex-col sm:flex-row overflow-hidden hover:border-blue-400 hover:bg-surface2 transition-colors"
[href]="props.url"
target="_blank"
rel="noopener noreferrer"
[style.background-color]="backgroundColor || null"
>
<div class="flex-1 min-w-0 px-4 py-3 flex flex-col gap-1 justify-center order-2 sm:order-1">
<div class="text-sm font-semibold text-neutral-100 truncate">
{{ displayTitle }}
</div>
<div class="flex items-center gap-2 text-xs">
@if (faviconUrl && !faviconBroken) {
<img [src]="faviconUrl" class="w-4 h-4 rounded-sm flex-shrink-0" alt="" (error)="onFaviconError()" />
}
<span class="truncate text-blue-400">{{ displayUrl }}</span>
</div>
@if (props.description) {
<div class="text-xs text-neutral-400 line-clamp-2">{{ props.description }}</div>
}
</div>
@if (props.imageUrl && showImage) {
<div class="sm:w-32 md:w-48 h-32 sm:h-auto sm:min-h-[80px] overflow-hidden flex-shrink-0 order-1 sm:order-2">
<img [src]="props.imageUrl" class="w-full h-full object-cover" alt="" />
</div>
}
</a>
}
}
} }
</div> </div>
`, `,
@ -80,11 +139,22 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
private resizeObserver?: ResizeObserver; private resizeObserver?: ResizeObserver;
pendingUrl = ''; pendingUrl = '';
showImage = true; showImage = true;
faviconBroken = false;
get props(): BookmarkProps { get props(): BookmarkProps {
return this.block.props; return this.block.props;
} }
get viewMode(): 'card' | 'tile' | 'cover' {
return this.props.viewMode || 'card';
}
get backgroundColor(): string | null {
const meta: any = this.block.meta || {};
const color = meta.bgColor;
return color && color !== 'transparent' ? color : null;
}
get displayHost(): string { get displayHost(): string {
try { try {
const u = new URL(this.props.url); const u = new URL(this.props.url);
@ -116,6 +186,28 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
} }
} }
get faviconUrl(): string | null {
// Prefer faviconUrl from props when available
const raw = (this.props.faviconUrl || '').trim();
if (raw) {
try {
// Support relative URLs returned by the preview API
const resolved = new URL(raw, this.props.url || undefined);
return resolved.toString();
} catch {
return raw;
}
}
// Fallback to /favicon.ico on the same origin as the page URL
try {
const pageUrl = new URL(this.props.url);
return pageUrl.origin + '/favicon.ico';
} catch {
return null;
}
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.props.url && this.urlInput?.nativeElement) { if (!this.props.url && this.urlInput?.nativeElement) {
setTimeout(() => this.urlInput?.nativeElement.focus(), 0); setTimeout(() => this.urlInput?.nativeElement.focus(), 0);
@ -172,6 +264,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
faviconUrl: this.props.faviconUrl, faviconUrl: this.props.faviconUrl,
loading: true, loading: true,
error: null, error: null,
viewMode: this.props.viewMode || 'card',
}; };
this.update.emit(next); this.update.emit(next);
this.loadPreview(url); this.loadPreview(url);
@ -189,6 +282,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
faviconUrl: data.faviconUrl || this.props.faviconUrl, faviconUrl: data.faviconUrl || this.props.faviconUrl,
loading: false, loading: false,
error: null, error: null,
viewMode: this.props.viewMode || 'card',
}; };
this.update.emit(next); this.update.emit(next);
} catch (e: any) { } catch (e: any) {
@ -202,6 +296,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
faviconUrl: this.props.faviconUrl, faviconUrl: this.props.faviconUrl,
loading: false, loading: false,
error: message, error: message,
viewMode: this.props.viewMode || 'card',
}; };
this.update.emit(next); this.update.emit(next);
} }
@ -220,6 +315,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
faviconUrl: this.props.faviconUrl, faviconUrl: this.props.faviconUrl,
loading: true, loading: true,
error: null, error: null,
viewMode: this.props.viewMode || 'card',
}; };
this.update.emit(next); this.update.emit(next);
this.loadPreview(this.props.url); this.loadPreview(this.props.url);
@ -259,4 +355,8 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
private updateShowImage(width: number): void { private updateShowImage(width: number): void {
this.showImage = width >= this.minimumWidthForImage; this.showImage = width >= this.minimumWidthForImage;
} }
onFaviconError(): void {
this.faviconBroken = true;
}
} }

View File

@ -1,35 +1,48 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'; import { Component, Input, Output, EventEmitter, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Block, ButtonProps } from '../../../core/models/block.model'; import { Block, ButtonProps } from '../../../core/models/block.model';
import { ButtonConfigModalComponent } from './button-config-modal.component';
@Component({ @Component({
selector: 'app-button-block', selector: 'app-button-block',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, ButtonConfigModalComponent],
template: ` template: `
<div class="flex items-center gap-4"> <div class="relative group inline-block">
<input <!-- Rendered Button -->
type="text"
class="input input-sm"
placeholder="Button label..."
[value]="props.label"
(input)="onLabelChange($event)"
/>
<input
type="text"
class="input input-sm flex-1"
placeholder="URL..."
[value]="props.url"
(input)="onUrlChange($event)"
/>
<a <a
[href]="props.url" [href]="props.url"
[class]="getButtonClass()" [target]="props.openInNewTab ? '_blank' : '_self'"
target="_blank" class="inline-flex items-center justify-center font-medium transition-all select-none no-underline cursor-pointer"
[ngClass]="getButtonClasses()"
[style.background-color]="getBgColor()"
[style.color]="getTextColor()"
(click)="onClick($event)"
> >
{{ props.label }} {{ props.label || 'Button' }}
</a> </a>
<!-- Edit Trigger (visible on hover/focus if not configuring) -->
<button
*ngIf="!showConfig()"
class="absolute -top-2 -right-2 bg-surface2 border border-app rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm z-10"
title="Configure button"
(click)="openConfig($event)"
>
<svg class="w-3 h-3 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<!-- Config Modal -->
@if (showConfig()) {
<app-button-config-modal
[props]="props"
[blockId]="block.id"
(saveProps)="onSaveConfig($event)"
(cancel)="closeConfig()"
/>
}
</div> </div>
` `
}) })
@ -37,27 +50,79 @@ export class ButtonBlockComponent {
@Input({ required: true }) block!: Block<ButtonProps>; @Input({ required: true }) block!: Block<ButtonProps>;
@Output() update = new EventEmitter<ButtonProps>(); @Output() update = new EventEmitter<ButtonProps>();
showConfig = signal(false);
ngOnInit() {
// If new button (empty label), open config immediately
if (!this.props.label) {
this.showConfig.set(true);
}
}
get props(): ButtonProps { get props(): ButtonProps {
return this.block.props; return this.block.props;
} }
onLabelChange(event: Event): void { getButtonClasses(): string {
const target = event.target as HTMLInputElement; const classes: string[] = [];
this.update.emit({ ...this.props, label: target.value });
}
onUrlChange(event: Event): void { // Size
const target = event.target as HTMLInputElement; switch (this.props.size) {
this.update.emit({ ...this.props, url: target.value }); case 'small': classes.push('px-3 py-1.5 text-xs'); break;
} case 'large': classes.push('px-6 py-3 text-base'); break;
default: classes.push('px-4 py-2 text-sm'); break; // medium
getButtonClass(): string {
const base = 'btn btn-sm';
switch (this.props.variant) {
case 'primary': return `${base} btn-primary`;
case 'secondary': return `${base} btn-secondary`;
case 'outline': return `${base} btn-outline`;
default: return base;
} }
// Shape
if (this.props.shape === 'pill') {
classes.push('rounded-full');
} else if (this.props.shape === 'rounded') {
classes.push('rounded-lg');
} else {
classes.push('rounded-md'); // default/square
}
// Variant / Style
if (this.props.variant === '3d') {
classes.push('border-b-4 border-black/20 active:border-b-0 active:translate-y-1');
} else if (this.props.variant === 'shadow') {
classes.push('shadow-lg hover:shadow-xl hover:-translate-y-0.5');
} else if (this.props.variant === 'outline') {
classes.push('border-2');
}
return classes.join(' ');
}
getBgColor(): string {
if (this.props.variant === 'outline') return 'transparent';
return this.props.backgroundColor || '#3b82f6';
}
getTextColor(): string {
if (this.props.variant === 'outline') {
return this.props.backgroundColor || '#3b82f6';
}
// Simple contrast check could be added here, assuming white for now for colored buttons
return '#ffffff';
}
onClick(event: MouseEvent) {
// Prevent navigation in editor
event.preventDefault();
}
openConfig(event: MouseEvent) {
event.stopPropagation();
this.showConfig.set(true);
}
closeConfig() {
this.showConfig.set(false);
}
onSaveConfig(newProps: ButtonProps) {
this.update.emit(newProps);
this.showConfig.set(false);
} }
} }

View File

@ -0,0 +1,241 @@
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonProps } from '../../../core/models/block.model';
@Component({
selector: 'app-button-config-modal',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="fixed inset-0 z-[12000] flex items-center justify-center bg-black/50 p-4" (click)="close()">
<div class="bg-surface1 border border-app rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200" (click)="$event.stopPropagation()">
<!-- Header -->
<div class="px-6 py-4 border-b border-app">
<h3 class="text-lg font-semibold text-neutral-100">Configurer le bouton</h3>
</div>
<!-- Body -->
<div class="p-6 space-y-6 max-h-[80vh] overflow-y-auto">
<!-- Label -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-300">Titre</label>
<input
type="text"
class="w-full bg-surface2 border border-app rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
[(ngModel)]="tempProps.label"
placeholder="Bouton"
/>
<div class="text-xs text-neutral-500">ID: #{{ blockId }}</div>
</div>
<!-- URL -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-300">URL ou Email</label>
<input
type="text"
class="w-full bg-surface2 border border-app rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
[(ngModel)]="tempProps.url"
placeholder="https://example.com"
/>
</div>
<!-- Open in new tab -->
<div class="flex items-center gap-2">
<input
type="checkbox"
id="openInNewTab"
class="rounded border-gray-600 bg-surface2 text-blue-500 focus:ring-blue-500/50"
[(ngModel)]="tempProps.openInNewTab"
/>
<label for="openInNewTab" class="text-sm text-neutral-300">Ouvrir dans un nouvel onglet</label>
</div>
<!-- Shape -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-300">Forme du bouton</label>
<div class="flex gap-2">
<button
type="button"
class="p-2 border rounded-lg transition-colors"
[class.border-blue-500]="tempProps.shape === 'pill'"
[class.bg-blue-500/10]="tempProps.shape === 'pill'"
[class.border-app]="tempProps.shape !== 'pill'"
[class.hover:bg-surface2]="tempProps.shape !== 'pill'"
(click)="tempProps.shape = 'pill'"
title="Pill"
>
<div class="w-8 h-6 rounded-full bg-neutral-400 flex items-center justify-center text-[10px] text-surface1 font-bold">a</div>
</button>
<button
type="button"
class="p-2 border rounded-lg transition-colors"
[class.border-blue-500]="tempProps.shape === 'rounded'"
[class.bg-blue-500/10]="tempProps.shape === 'rounded'"
[class.border-app]="tempProps.shape !== 'rounded'"
[class.hover:bg-surface2]="tempProps.shape !== 'rounded'"
(click)="tempProps.shape = 'rounded'"
title="Rounded"
>
<div class="w-8 h-6 rounded bg-neutral-400 flex items-center justify-center text-[10px] text-surface1 font-bold">a</div>
</button>
</div>
</div>
<!-- Background Color -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-300">Arrière-plan</label>
<div class="flex gap-2 flex-wrap">
@for (color of colors; track color) {
<button
type="button"
class="w-6 h-6 rounded-full border transition-transform hover:scale-110"
[style.background-color]="color"
[class.ring-2]="tempProps.backgroundColor === color"
[class.ring-offset-2]="tempProps.backgroundColor === color"
[class.ring-offset-surface1]="tempProps.backgroundColor === color"
[class.ring-blue-500]="tempProps.backgroundColor === color"
[class.border-gray-600]="tempProps.backgroundColor !== color"
(click)="tempProps.backgroundColor = color"
></button>
}
</div>
</div>
<!-- Size -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-300">Taille du bouton</label>
<div class="flex bg-surface2 rounded-lg p-1 border border-app w-max">
<button
type="button"
class="px-3 py-1 rounded text-xs font-medium transition-colors"
[class.bg-blue-600]="tempProps.size === 'small'"
[class.text-white]="tempProps.size === 'small'"
[class.text-neutral-400]="tempProps.size !== 'small'"
[class.hover:text-neutral-200]="tempProps.size !== 'small'"
(click)="tempProps.size = 'small'"
>
Petit
</button>
<button
type="button"
class="px-3 py-1 rounded text-xs font-medium transition-colors"
[class.bg-blue-600]="tempProps.size === 'medium'"
[class.text-white]="tempProps.size === 'medium'"
[class.text-neutral-400]="tempProps.size !== 'medium'"
[class.hover:text-neutral-200]="tempProps.size !== 'medium'"
(click)="tempProps.size = 'medium'"
>
Moyen
</button>
<button
type="button"
class="px-3 py-1 rounded text-xs font-medium transition-colors"
[class.bg-blue-600]="tempProps.size === 'large'"
[class.text-white]="tempProps.size === 'large'"
[class.text-neutral-400]="tempProps.size !== 'large'"
[class.hover:text-neutral-200]="tempProps.size !== 'large'"
(click)="tempProps.size = 'large'"
>
Grand
</button>
</div>
</div>
<!-- Variant / Type -->
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-300">Type de bouton</label>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" name="btnVariant" value="3d" [(ngModel)]="tempProps.variant" class="text-blue-500 bg-surface2 border-gray-600 focus:ring-blue-500/50">
<span class="text-sm text-neutral-300 group-hover:text-neutral-100">Bouton 3D</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" name="btnVariant" value="shadow" [(ngModel)]="tempProps.variant" class="text-blue-500 bg-surface2 border-gray-600 focus:ring-blue-500/50">
<span class="text-sm text-neutral-300 group-hover:text-neutral-100">Bouton avec ombre</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" name="btnVariant" value="default" [(ngModel)]="tempProps.variant" class="text-blue-500 bg-surface2 border-gray-600 focus:ring-blue-500/50">
<span class="text-sm text-neutral-300 group-hover:text-neutral-100">Par défaut</span>
</label>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-app flex justify-end gap-3">
<button
type="button"
class="px-4 py-2 rounded-lg text-sm font-medium text-neutral-300 hover:bg-surface2 transition-colors"
(click)="close()"
>
Annuler
</button>
<button
type="button"
class="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-600 hover:bg-cyan-500 text-white transition-colors"
(click)="save()"
>
Terminé
</button>
</div>
</div>
</div>
`,
styles: [`
:host {
display: block;
}
`]
})
export class ButtonConfigModalComponent {
@Input({ required: true }) props!: ButtonProps;
@Input() blockId: string = '';
@Output() saveProps = new EventEmitter<ButtonProps>();
@Output() cancel = new EventEmitter<void>();
// Palette colors (based on image)
colors = [
'#3b82f6', // blue
'#ef4444', // red
'#f59e0b', // orange
'#10b981', // green
'#8b5cf6', // purple
'#ec4899', // pink
'#6b7280', // gray
];
tempProps: ButtonProps = {
label: '',
url: '',
openInNewTab: false,
shape: 'pill',
backgroundColor: '#3b82f6',
size: 'medium',
variant: 'default'
};
ngOnInit() {
// Initialize temp props with input props, providing defaults
this.tempProps = {
label: this.props.label || 'Bouton',
url: this.props.url || '',
openInNewTab: this.props.openInNewTab ?? false,
shape: this.props.shape || 'pill',
backgroundColor: this.props.backgroundColor || '#3b82f6',
size: this.props.size || 'medium',
variant: this.props.variant || 'default'
};
}
close() {
this.cancel.emit();
}
save() {
this.saveProps.emit(this.tempProps);
}
}

View File

@ -405,6 +405,26 @@ export class ColumnsBlockComponent implements AfterViewInit, OnDestroy {
this.update.emit({ columns: updatedColumns }); this.update.emit({ columns: updatedColumns });
} }
private setBookmarkViewModeInColumns(blockId: string, mode: 'card' | 'tile' | 'cover'): void {
const updatedColumns = this.props.columns.map(column => ({
...column,
blocks: column.blocks.map(b => {
if (b.id === blockId && b.type === 'bookmark') {
return {
...b,
props: {
...(b.props as any),
viewMode: mode
}
};
}
return b;
})
}));
this.update.emit({ columns: updatedColumns });
}
onBlockCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void { onBlockCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void {
// Create a new paragraph block after the specified block in the same column // Create a new paragraph block after the specified block in the same column
const updatedColumns = this.props.columns.map((column, colIdx) => { const updatedColumns = this.props.columns.map((column, colIdx) => {
@ -566,6 +586,14 @@ export class ColumnsBlockComponent implements AfterViewInit, OnDestroy {
this.backgroundColorBlockInColumns(block.id, color); this.backgroundColorBlockInColumns(block.id, color);
} }
// Handle bookmark view mode (card / tile / cover) inside columns
if (action.type === 'bookmarkViewMode') {
const mode = (action.payload || {}).viewMode as 'card' | 'tile' | 'cover' | undefined;
if (mode === 'card' || mode === 'tile' || mode === 'cover') {
this.setBookmarkViewModeInColumns(block.id, mode);
}
}
// Handle convert action // Handle convert action
if (action.type === 'convert') { if (action.type === 'convert') {
// Convert the block type within the columns // Convert the block type within the columns
@ -770,7 +798,7 @@ export class ColumnsBlockComponent implements AfterViewInit, OnDestroy {
getBlockBgColor(block: Block): string | undefined { getBlockBgColor(block: Block): string | undefined {
// Paragraph, heading and list(-item) blocks in columns should not have a full-width // Paragraph, heading and list(-item) blocks in columns should not have a full-width
// background; their inner editable/input pill handles the colored capsule. // background; their inner editable/input pill handles the colored capsule.
if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list' || block.type === 'list-item') { if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list' || block.type === 'list-item' || block.type === 'bookmark') {
return undefined; return undefined;
} }
const bgColor = (block.meta as any)?.bgColor; const bgColor = (block.meta as any)?.bgColor;

View File

@ -1,7 +1,8 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'; import { Component, Input, Output, EventEmitter, inject, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Block, EmbedProps } from '../../../core/models/block.model'; import { Block, EmbedProps } from '../../../core/models/block.model';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
@Component({ @Component({
selector: 'app-embed-block', selector: 'app-embed-block',
@ -9,17 +10,42 @@ import { Block, EmbedProps } from '../../../core/models/block.model';
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
template: ` template: `
@if (props.url) { @if (props.url) {
<div class="border rounded-xl overflow-hidden"> <div
<div class="aspect-video bg-surface2"> class="border rounded-xl overflow-hidden group bg-surface2"
(mouseenter)="showHandle = true"
(mouseleave)="showHandle = false"
>
<div
#frameContainer
class="relative"
[class.w-full]="!props.width"
[style.width.px]="props.width || null"
[style.height.px]="props.height || 400"
>
<iframe <iframe
[src]="getSafeUrl()" [src]="getSafeUrl()"
class="w-full h-full" class="w-full h-full border-0"
[sandbox]="props.sandbox ? 'allow-scripts allow-same-origin' : undefined"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
loading="lazy" loading="lazy"
></iframe> ></iframe>
@if (showHandle) {
<!-- Poignée principale (texte) en bas à droite -->
<div
class="absolute right-2 bottom-2 flex items-center gap-1 text-[11px] text-neutral-200 bg-neutral-900/70 px-2 py-1 rounded-full cursor-row-resize select-none"
(mousedown)="onResizeStart($event)"
>
<span>Redimensionner</span>
</div>
<!-- Poignées discrètes aux 4 coins pour un redimensionnement plus fin -->
<div class="embed-resize-handle corner top-left" (mousedown)="onResizeStart($event, 'nw')"></div>
<div class="embed-resize-handle corner top-right" (mousedown)="onResizeStart($event, 'ne')"></div>
<div class="embed-resize-handle corner bottom-left" (mousedown)="onResizeStart($event, 'sw')"></div>
<div class="embed-resize-handle corner bottom-right" (mousedown)="onResizeStart($event, 'se')"></div>
}
</div> </div>
<div class="p-2 bg-surface1 text-xs text-text-muted truncate"> <div class="p-2 bg-surface1 text-xs text-text-muted truncate border-t border-neutral-800">
{{ props.url }} {{ props.url }}
</div> </div>
</div> </div>
@ -33,16 +59,61 @@ import { Block, EmbedProps } from '../../../core/models/block.model';
/> />
</div> </div>
} }
` `,
styles: [`
.embed-resize-handle {
position: absolute;
width: 10px;
height: 10px;
border-radius: 9999px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(248, 250, 252, 0.7);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.9);
cursor: ns-resize;
z-index: 10;
}
.embed-resize-handle.corner.top-left {
top: 4px;
left: 4px;
}
.embed-resize-handle.corner.top-right {
top: 4px;
right: 4px;
}
.embed-resize-handle.corner.bottom-left {
bottom: 4px;
left: 4px;
}
.embed-resize-handle.corner.bottom-right {
bottom: 4px;
right: 4px;
}
`]
}) })
export class EmbedBlockComponent { export class EmbedBlockComponent {
@Input({ required: true }) block!: Block<EmbedProps>; @Input({ required: true }) block!: Block<EmbedProps>;
@Output() update = new EventEmitter<EmbedProps>(); @Output() update = new EventEmitter<EmbedProps>();
private sanitizer = inject(DomSanitizer);
@ViewChild('frameContainer') frameContainer?: ElementRef<HTMLDivElement>;
get props(): EmbedProps { get props(): EmbedProps {
return this.block.props; return this.block.props;
} }
showHandle = false;
private resizing = false;
private resizeDir: 'nw' | 'ne' | 'sw' | 'se' | null = null;
private startX = 0;
private startY = 0;
private startWidth = 0;
private startHeight = 0;
onUrlChange(event: Event): void { onUrlChange(event: Event): void {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const url = target.value; const url = target.value;
@ -50,21 +121,85 @@ export class EmbedBlockComponent {
this.update.emit({ ...this.props, url, provider }); this.update.emit({ ...this.props, url, provider });
} }
getSafeUrl(): string { onResizeStart(event: MouseEvent, dir: 'nw' | 'ne' | 'sw' | 'se' = 'se'): void {
event.preventDefault();
event.stopPropagation();
const container = this.frameContainer?.nativeElement;
const currentWidth = this.props.width || container?.clientWidth || 800;
const currentHeight = this.props.height || container?.clientHeight || 400;
this.resizing = true;
this.resizeDir = dir;
this.startX = event.clientX;
this.startY = event.clientY;
this.startWidth = currentWidth;
this.startHeight = currentHeight;
window.addEventListener('mousemove', this.onResizeMove);
window.addEventListener('mouseup', this.onResizeEnd);
}
private onResizeMove = (event: MouseEvent): void => {
if (!this.resizing) return;
const dx = event.clientX - this.startX;
const dy = event.clientY - this.startY;
let width = this.startWidth;
let height = this.startHeight;
switch (this.resizeDir) {
case 'se':
width = this.startWidth + dx;
height = this.startHeight + dy;
break;
case 'sw':
width = this.startWidth - dx;
height = this.startHeight + dy;
break;
case 'ne':
width = this.startWidth + dx;
height = this.startHeight - dy;
break;
case 'nw':
width = this.startWidth - dx;
height = this.startHeight - dy;
break;
default:
break;
}
const minWidth = 320;
const minHeight = 200;
const nextWidth = Math.max(minWidth, width);
const nextHeight = Math.max(minHeight, height);
this.update.emit({ ...this.props, width: nextWidth, height: nextHeight });
};
private onResizeEnd = (): void => {
if (!this.resizing) return;
this.resizing = false;
this.resizeDir = null;
window.removeEventListener('mousemove', this.onResizeMove);
window.removeEventListener('mouseup', this.onResizeEnd);
};
getSafeUrl(): SafeResourceUrl {
// Transform URLs for embedding // Transform URLs for embedding
let url = this.props.url; let url = this.props.url;
// YouTube // YouTube
if (url.includes('youtube.com/watch')) { if (url.includes('youtube.com/watch')) {
const videoId = new URL(url).searchParams.get('v'); const videoId = new URL(url).searchParams.get('v');
return `https://www.youtube.com/embed/${videoId}`; return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube.com/embed/${videoId}`);
} }
if (url.includes('youtu.be/')) { if (url.includes('youtu.be/')) {
const videoId = url.split('youtu.be/')[1].split('?')[0]; const videoId = url.split('youtu.be/')[1].split('?')[0];
return `https://www.youtube.com/embed/${videoId}`; return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube.com/embed/${videoId}`);
} }
return url; return this.sanitizer.bypassSecurityTrustResourceUrl(url);
} }
detectProvider(url: string): 'youtube' | 'gdrive' | 'maps' | 'generic' { detectProvider(url: string): 'youtube' | 'gdrive' | 'maps' | 'generic' {

View File

@ -6,13 +6,14 @@ import { Block, ParagraphProps } from '../../../core/models/block.model';
import { DocumentService } from '../../../services/document.service'; import { DocumentService } from '../../../services/document.service';
import { SelectionService } from '../../../services/selection.service'; import { SelectionService } from '../../../services/selection.service';
import { PaletteService } from '../../../services/palette.service'; import { PaletteService } from '../../../services/palette.service';
import { UrlPreviewService } from '../../../services/url-preview.service';
import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS } from '../../../core/constants/palette-items'; import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS } from '../../../core/constants/palette-items';
import { UrlPasteMenuComponent, UrlPasteAction } from '../url-paste-menu.component';
@Component({ @Component({
selector: 'app-paragraph-block', selector: 'app-paragraph-block',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, BlockInlineToolbarComponent], imports: [CommonModule, FormsModule, BlockInlineToolbarComponent, UrlPasteMenuComponent],
template: ` template: `
<div <div
class="relative" class="relative"
@ -33,6 +34,7 @@ import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS
contenteditable="true" contenteditable="true"
class="m-0 inline-block bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1rem]" class="m-0 inline-block bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1rem]"
(input)="onInput($event)" (input)="onInput($event)"
(paste)="onPaste($event)"
(keydown)="onKeyDown($event)" (keydown)="onKeyDown($event)"
(focus)="isFocused.set(true)" (focus)="isFocused.set(true)"
(blur)="onBlur($event)" (blur)="onBlur($event)"
@ -51,6 +53,16 @@ import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS
</div> </div>
</app-block-inline-toolbar> </app-block-inline-toolbar>
<!-- Url Paste Menu -->
@if (pasteMenuVisible()) {
<app-url-paste-menu
[url]="pastedUrl()"
[position]="pasteMenuPosition()"
(action)="onPasteMenuAction($event)"
(cancel)="onPasteMenuCancel()"
/>
}
<!-- Anchored dropdown menu (more) - used only inside columns layout --> <!-- Anchored dropdown menu (more) - used only inside columns layout -->
@if (inColumn && moreOpen()) { @if (inColumn && moreOpen()) {
<div <div
@ -151,6 +163,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
private documentService = inject(DocumentService); private documentService = inject(DocumentService);
private selectionService = inject(SelectionService); private selectionService = inject(SelectionService);
private paletteService = inject(PaletteService); private paletteService = inject(PaletteService);
private urlPreview = inject(UrlPreviewService);
@ViewChild('editable', { static: true }) editable?: ElementRef<HTMLDivElement>; @ViewChild('editable', { static: true }) editable?: ElementRef<HTMLDivElement>;
@ViewChild('menuPanel') menuPanel?: ElementRef<HTMLDivElement>; @ViewChild('menuPanel') menuPanel?: ElementRef<HTMLDivElement>;
@ -168,12 +181,140 @@ export class ParagraphBlockComponent implements AfterViewInit {
private slashCommandActive = false; private slashCommandActive = false;
private slashCommandStartOffset = -1; private slashCommandStartOffset = -1;
// Url Paste Menu State
pasteMenuVisible = signal(false);
pastedUrl = signal('');
pasteMenuPosition = signal({ x: 0, y: 0 });
getBlockBgColor(): string | undefined { getBlockBgColor(): string | undefined {
const meta: any = this.block?.meta || {}; const meta: any = this.block?.meta || {};
const bgColor = meta.bgColor; const bgColor = meta.bgColor;
return bgColor && bgColor !== 'transparent' ? bgColor : undefined; return bgColor && bgColor !== 'transparent' ? bgColor : undefined;
} }
onPaste(event: ClipboardEvent): void {
const clipboardData = event.clipboardData;
const pastedText = clipboardData?.getData('text') || '';
// Check if pasted text looks like a URL
// Simple regex for http/https
if (/^https?:\/\/[^\s/$.?#].[^\s]*$/i.test(pastedText.trim())) {
event.preventDefault();
// Save URL
this.pastedUrl.set(pastedText.trim());
// Calculate position for the menu à partir du caret, comme pour le slash-menu,
// afin qu'il apparaisse juste sous l'endroit où l'URL est collée.
let rect: DOMRect | null = null;
try {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0).cloneRange();
if (range.getClientRects && range.getClientRects().length > 0) {
rect = range.getClientRects()[0];
} else {
const span = document.createElement('span');
span.textContent = '\u200b';
range.insertNode(span);
rect = span.getBoundingClientRect();
span.parentNode?.removeChild(span);
}
}
} catch {
rect = null;
}
if (!rect) {
const editableEl = this.editable?.nativeElement;
if (editableEl && editableEl.getBoundingClientRect) {
rect = editableEl.getBoundingClientRect();
}
}
let x = 0;
let y = 0;
const vw = window.innerWidth;
const vh = window.innerHeight;
const estimatedMenuHeight = 180;
const estimatedMenuWidth = 260;
if (rect) {
x = rect.left;
y = rect.bottom + 8;
if (x + estimatedMenuWidth > vw - 8) {
x = Math.max(8, vw - estimatedMenuWidth - 8);
}
if (x < 8) {
x = 8;
}
// Si pas assez de place en bas, le placer au-dessus du caret / prompt
if (y + estimatedMenuHeight > vh - 8) {
y = rect.top - estimatedMenuHeight - 8;
}
if (y < 8) {
y = 8;
}
}
this.pasteMenuPosition.set({ x, y });
// Show menu
this.pasteMenuVisible.set(true);
}
}
onPasteMenuCancel(): void {
this.pasteMenuVisible.set(false);
}
onPasteMenuAction(event: { type: UrlPasteAction, title?: string }): void {
const url = this.pastedUrl();
const { type, title } = event;
this.pasteMenuVisible.set(false);
if (type === 'url') {
// Insert URL as plain text (or link?) - Request said "affiche l'url dans le bloc paragraphe"
// We will replace the paragraph content with the URL
this.update.emit({ text: url });
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = url;
}
} else if (type === 'title') {
// Replace content with title
const textToInsert = title || url;
this.update.emit({ text: textToInsert });
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = textToInsert;
}
} else if (type === 'embed') {
this.handleEmbedAction(url, title);
} else if (type === 'bookmark') {
// Convert to bookmark block
this.documentService.updateBlock(this.block.id, {
type: 'bookmark',
props: {
url: url,
title: title,
viewMode: 'card'
}
} as any);
} else if (type === 'button') {
// Convert to button block
this.documentService.updateBlock(this.block.id, {
type: 'button',
props: {
label: title || 'Button',
url: url,
variant: 'primary'
}
} as any);
}
}
onInlineAction(type: any): void { onInlineAction(type: any): void {
if (type === 'more' || type === 'menu') { if (type === 'more' || type === 'menu') {
// In both normal flow and columns, open the global block palette // In both normal flow and columns, open the global block palette
@ -643,6 +784,68 @@ export class ParagraphBlockComponent implements AfterViewInit {
} }
} }
private async handleEmbedAction(url: string, title?: string): Promise<void> {
// Try to decide if this URL can be safely embedded. Some major sites
// (Google, Bing, etc.) explicitly disallow iframes via X-Frame-Options
// or CSP. For those, we fallback to a rich bookmark card instead of an
// empty/broken embed frame.
let previewHost = '';
let previewData: any = null;
try {
previewData = await this.urlPreview.getPreview(url);
const effectiveUrl = previewData?.url || url;
try {
previewHost = new URL(effectiveUrl).host;
} catch {
previewHost = '';
}
} catch {
// If preview fails we just keep previewData = null and fall back to
// normal embed behaviour below.
}
const noEmbedHosts = [
'google.com',
'www.google.com',
'bing.com',
'www.bing.com'
];
const isNoEmbed = !!previewHost && noEmbedHosts.some(h =>
previewHost === h || previewHost.endsWith('.' + h)
);
if (isNoEmbed) {
// Fallback: create a rich bookmark card instead of an embed block.
this.documentService.updateBlock(this.block.id, {
type: 'bookmark',
props: {
url: previewData?.url || url,
title: previewData?.title || title || '',
description: previewData?.description || '',
siteName: previewData?.siteName || '',
imageUrl: previewData?.imageUrl || undefined,
faviconUrl: previewData?.faviconUrl || undefined,
// Always use the "cover" layout: big visual card with image when
// available, or a pure text band when there is no image (like the
// Google example).
viewMode: 'cover'
}
} as any);
return;
}
// Default behaviour: convert to an embed block.
this.documentService.updateBlock(this.block.id, {
type: 'embed',
props: {
url: url,
provider: 'generic'
}
} as any);
}
onMenuFocusOut(event: FocusEvent): void { onMenuFocusOut(event: FocusEvent): void {
if (!this.menuPanel) return; if (!this.menuPanel) return;
const panel = this.menuPanel.nativeElement; const panel = this.menuPanel.nativeElement;

View File

@ -0,0 +1,180 @@
import { Component, EventEmitter, Input, Output, inject, signal, OnInit, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UrlPreviewService } from '../../services/url-preview.service';
export type UrlPasteAction = 'url' | 'title' | 'embed' | 'bookmark' | 'button';
@Component({
selector: 'app-url-paste-menu',
standalone: true,
imports: [CommonModule],
template: `
<div
#menuRoot
class="fixed z-[12000] bg-surface1 rounded-lg shadow-surface-md border border-app py-1 min-w-[200px] animate-in fade-in zoom-in-95 duration-150"
[style.top.px]="position.y"
[style.left.px]="position.x"
tabindex="-1"
(keydown)="onKeyDown($event)"
>
<div class="px-3 py-2 text-xs text-neutral-400 border-b border-app mb-1 truncate max-w-[300px]">
{{ url }}
</div>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-neutral-100 hover:bg-surface2 flex items-center gap-3 transition-colors"
(click)="select('url')"
>
<div class="w-5 h-5 flex items-center justify-center text-neutral-400">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
URL
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-neutral-100 hover:bg-surface2 flex items-center gap-3 transition-colors"
(click)="select('title')"
>
<div class="w-5 h-5 flex items-center justify-center text-neutral-400">
<span class="font-serif font-bold">T</span>
</div>
<div class="flex flex-col">
<span>Titre</span>
@if (siteTitle()) {
<span class="text-[10px] text-neutral-400 truncate max-w-[200px]">{{ siteTitle() }}</span>
} @else if (loading()) {
<span class="text-[10px] text-neutral-500 animate-pulse">Loading title...</span>
}
</div>
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-neutral-100 hover:bg-surface2 flex items-center gap-3 transition-colors"
(click)="select('embed')"
>
<div class="w-5 h-5 flex items-center justify-center text-neutral-400">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
Intégrer
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-neutral-100 hover:bg-surface2 flex items-center gap-3 transition-colors"
(click)="select('bookmark')"
>
<div class="w-5 h-5 flex items-center justify-center text-neutral-400">
<span class="text-sm">🔖</span>
</div>
Marque-page
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-neutral-100 hover:bg-surface2 flex items-center gap-3 transition-colors"
(click)="select('button')"
>
<div class="w-5 h-5 flex items-center justify-center text-neutral-400">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<rect x="4" y="7" width="16" height="10" rx="2" stroke-width="2" />
</svg>
</div>
Bouton
</button>
</div>
`,
styles: [`
:host {
display: block;
}
`]
})
export class UrlPasteMenuComponent implements OnInit {
@Input({ required: true }) url!: string;
@Input({ required: true }) position!: { x: number; y: number };
@Output() action = new EventEmitter<{ type: UrlPasteAction, title?: string }>();
@Output() cancel = new EventEmitter<void>();
@ViewChild('menuRoot') menuRoot?: ElementRef<HTMLDivElement>;
private urlPreview = inject(UrlPreviewService);
siteTitle = signal<string>('');
loading = signal(false);
ngOnInit() {
this.fetchTitle();
}
ngAfterViewInit(): void {
setTimeout(() => {
const root = this.menuRoot?.nativeElement;
const firstButton = root?.querySelector<HTMLButtonElement>('button');
firstButton?.focus();
}, 0);
}
async fetchTitle() {
this.loading.set(true);
try {
const data = await this.urlPreview.getPreview(this.url);
if (data.title) {
this.siteTitle.set(data.title);
}
} catch (e) {
// silent fail
} finally {
this.loading.set(false);
}
}
select(type: UrlPasteAction) {
this.action.emit({ type, title: this.siteTitle() });
}
onKeyDown(event: KeyboardEvent): void {
const root = this.menuRoot?.nativeElement;
if (!root) return;
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button'));
if (!buttons.length) return;
const active = document.activeElement as HTMLElement | null;
let index = buttons.findIndex(btn => btn === active);
if (event.key === 'ArrowDown') {
event.preventDefault();
index = index < 0 ? 0 : Math.min(buttons.length - 1, index + 1);
buttons[index].focus();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
index = index < 0 ? buttons.length - 1 : Math.max(0, index - 1);
buttons[index].focus();
return;
}
if (event.key === 'Enter' || event.key === ' ') {
if (active && active.tagName === 'BUTTON') {
event.preventDefault();
(active as HTMLButtonElement).click();
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.cancel.emit();
return;
}
}
}

View File

@ -188,7 +188,11 @@ export interface FileProps {
export interface ButtonProps { export interface ButtonProps {
label: string; label: string;
url: string; url: string;
variant?: 'primary' | 'secondary' | 'outline'; variant?: 'primary' | 'secondary' | 'outline' | '3d' | 'shadow' | 'default';
openInNewTab?: boolean;
shape?: 'pill' | 'rounded' | 'square';
backgroundColor?: string;
size?: 'small' | 'medium' | 'large';
} }
export interface LinkProps { export interface LinkProps {
@ -292,6 +296,7 @@ export interface BookmarkProps {
faviconUrl?: string; faviconUrl?: string;
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
viewMode?: 'card' | 'tile' | 'cover';
} }
/** /**

View File

@ -575,7 +575,7 @@ export class DocumentService {
{ id: generateId(), blocks: [], width: 50 } { id: generateId(), blocks: [], width: 50 }
] ]
}; };
case 'bookmark': return { url: '', title: '', description: '', siteName: '', imageUrl: '', faviconUrl: '', loading: false, error: null }; case 'bookmark': return { url: '', title: '', description: '', siteName: '', imageUrl: '', faviconUrl: '', loading: false, error: null, viewMode: 'card' };
default: return {}; default: return {};
} }
} }

File diff suppressed because one or more lines are too long