```
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:
parent
b695095593
commit
6b47ec39ff
291
docs/URL_PASTE_BUTTON_FEATURE.md
Normal file
291
docs/URL_PASTE_BUTTON_FEATURE.md
Normal 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.
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -37,19 +37,76 @@ 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 {
|
||||||
|
@switch (viewMode) {
|
||||||
|
@case ('tile') {
|
||||||
|
<!-- Compact horizontal tile view: favicon + title on first line, URL on second line -->
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
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">
|
||||||
|
<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()" />
|
||||||
|
}
|
||||||
|
<div class="text-sm font-semibold text-neutral-100 truncate">
|
||||||
|
{{ displayTitle }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs truncate text-blue-400">
|
||||||
|
{{ displayUrl }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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
|
<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"
|
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"
|
[href]="props.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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="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">
|
<div class="text-sm font-semibold text-neutral-100 truncate">
|
||||||
{{ displayTitle }}
|
{{ displayTitle }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-xs">
|
<div class="flex items-center gap-2 text-xs">
|
||||||
@if (props.faviconUrl) {
|
@if (faviconUrl && !faviconBroken) {
|
||||||
<img [src]="props.faviconUrl" class="w-4 h-4 rounded-sm flex-shrink-0" alt="" />
|
<img [src]="faviconUrl" class="w-4 h-4 rounded-sm flex-shrink-0" alt="" (error)="onFaviconError()" />
|
||||||
}
|
}
|
||||||
<span class="truncate text-blue-400">{{ displayUrl }}</span>
|
<span class="truncate text-blue-400">{{ displayUrl }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -64,6 +121,8 @@ import { UrlPreviewService } from '../../../services/url-preview.service';
|
|||||||
}
|
}
|
||||||
</a>
|
</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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
|
||||||
|
// Size
|
||||||
|
switch (this.props.size) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
onUrlChange(event: Event): void {
|
// Shape
|
||||||
const target = event.target as HTMLInputElement;
|
if (this.props.shape === 'pill') {
|
||||||
this.update.emit({ ...this.props, url: target.value });
|
classes.push('rounded-full');
|
||||||
|
} else if (this.props.shape === 'rounded') {
|
||||||
|
classes.push('rounded-lg');
|
||||||
|
} else {
|
||||||
|
classes.push('rounded-md'); // default/square
|
||||||
}
|
}
|
||||||
|
|
||||||
getButtonClass(): string {
|
// Variant / Style
|
||||||
const base = 'btn btn-sm';
|
if (this.props.variant === '3d') {
|
||||||
switch (this.props.variant) {
|
classes.push('border-b-4 border-black/20 active:border-b-0 active:translate-y-1');
|
||||||
case 'primary': return `${base} btn-primary`;
|
} else if (this.props.variant === 'shadow') {
|
||||||
case 'secondary': return `${base} btn-secondary`;
|
classes.push('shadow-lg hover:shadow-xl hover:-translate-y-0.5');
|
||||||
case 'outline': return `${base} btn-outline`;
|
} else if (this.props.variant === 'outline') {
|
||||||
default: return base;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
</div>
|
||||||
<div class="p-2 bg-surface1 text-xs text-text-muted truncate">
|
|
||||||
|
<!-- 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 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' {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
180
src/app/editor/components/block/url-paste-menu.component.ts
Normal file
180
src/app/editor/components/block/url-paste-menu.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user