@karbonjs/ui-svelte 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karbonjs/ui-svelte",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Karbon UI components for Svelte 5",
5
5
  "type": "module",
6
6
  "svelte": "src/index.ts",
@@ -15,7 +15,7 @@
15
15
  "src"
16
16
  ],
17
17
  "dependencies": {
18
- "@karbonjs/ui-core": "0.1.0"
18
+ "@karbonjs/ui-core": "0.2.0"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "svelte": "^5.0.0"
@@ -57,8 +57,9 @@
57
57
  })
58
58
  </script>
59
59
 
60
- <!-- svelte-ignore a11y_no_static_element_interactions -->
61
60
  <div
61
+ role="region"
62
+ aria-label="Carousel"
62
63
  class="relative overflow-hidden rounded-xl {className}"
63
64
  onmouseenter={stopAutoplay}
64
65
  onmouseleave={startAutoplay}
@@ -30,12 +30,10 @@
30
30
 
31
31
  <svelte:window onclick={() => open = false} />
32
32
 
33
- <!-- svelte-ignore a11y_click_events_have_key_events -->
34
- <!-- svelte-ignore a11y_no_static_element_interactions -->
35
- <div class="relative inline-block {className}" onclick={(e) => e.stopPropagation()}>
36
- <div onclick={() => open = !open} class="cursor-pointer">
33
+ <div class="relative inline-block {className}">
34
+ <button type="button" onclick={(e) => { e.stopPropagation(); open = !open }} class="cursor-pointer bg-transparent border-none p-0 m-0">
37
35
  {@render trigger()}
38
- </div>
36
+ </button>
39
37
 
40
38
  {#if open}
41
39
  <div class="absolute z-50 mt-1 min-w-[12rem] rounded-xl border border-[var(--karbon-border,rgba(0,0,0,0.07))] bg-[var(--karbon-bg-card,#fff)] shadow-xl py-1
@@ -0,0 +1,882 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from 'svelte'
3
+ import type { MediaProvider } from '@karbonjs/ui-core'
4
+
5
+ interface Props {
6
+ value: string
7
+ placeholder?: string
8
+ media?: MediaProvider
9
+ class?: string
10
+ }
11
+
12
+ let {
13
+ value = $bindable(''),
14
+ placeholder = 'Rédigez votre contenu...',
15
+ media,
16
+ class: className = ''
17
+ }: Props = $props()
18
+
19
+ // ── DOM refs ──
20
+ let editor = $state<HTMLDivElement>(undefined!)
21
+ let sourceEl = $state<HTMLTextAreaElement>(undefined!)
22
+ let lineNumbers = $state<HTMLDivElement>(undefined!)
23
+
24
+ // ── Editor state ──
25
+ let sourceMode = $state(false)
26
+ let sourceCode = $state('')
27
+ let fullscreen = $state(false)
28
+ let activeFormats = $state<Set<string>>(new Set())
29
+ let savedRange: Range | null = null
30
+ let wordCount = $state(0)
31
+ let charCount = $state(0)
32
+
33
+ // ── Modals ──
34
+ let showLinkModal = $state(false)
35
+ let showImageModal = $state(false)
36
+ let showImagePropsModal = $state(false)
37
+ let showTableModal = $state(false)
38
+ let showColorPicker = $state(false)
39
+ let showMediaExplorer = $state(false)
40
+ let showEmbedModal = $state(false)
41
+ let showFindReplace = $state(false)
42
+ let showContextMenu = $state(false)
43
+ let showElementProps = $state(false)
44
+
45
+ let mediaExplorerContext = $state<'editor' | 'imageModal'>('editor')
46
+
47
+ // ── Link state ──
48
+ let linkUrl = $state('')
49
+ let linkText = $state('')
50
+ let linkTarget = $state(true)
51
+ let linkTitle = $state('')
52
+ let linkClass = $state('')
53
+
54
+ // ── Image state ──
55
+ let imageUrl = $state('')
56
+ let imageAlt = $state('')
57
+ let imageTitle = $state('')
58
+ let imageClass = $state('')
59
+ let imageStyle = $state('')
60
+ let imageWidth = $state('')
61
+ let imageHeight = $state('')
62
+ let imageAlign = $state('')
63
+ let imageUploading = $state(false)
64
+ let targetImage: HTMLImageElement | null = null
65
+
66
+ // ── Table state ──
67
+ let tableRows = $state(3)
68
+ let tableCols = $state(3)
69
+ let targetTable: HTMLTableElement | null = null
70
+ let targetCell: HTMLTableCellElement | null = null
71
+
72
+ // ── Embed state ──
73
+ let embedUrl = $state('')
74
+
75
+ // ── Find/Replace ──
76
+ let findText = $state('')
77
+ let replaceText = $state('')
78
+
79
+ // ── Context menu ──
80
+ let contextPos = $state({ x: 0, y: 0 })
81
+ let contextTarget = $state<HTMLElement | null>(null)
82
+ let contextType = $state<'image' | 'link' | 'table' | 'general'>('general')
83
+
84
+ // ── Element properties ──
85
+ let elPropsTag = $state('')
86
+ let elPropsClass = $state('')
87
+ let elPropsStyle = $state('')
88
+ let elPropsId = $state('')
89
+ let elPropsTarget = $state<HTMLElement | null>(null)
90
+
91
+ // ── Media explorer state ──
92
+ let mediaFiles = $state<any[]>([])
93
+ let mediaLoading = $state(false)
94
+ let mediaSearch = $state('')
95
+ let mediaPage = $state(1)
96
+ let mediaTotal = $state(0)
97
+ let mediaSelected = $state<any>(null)
98
+ let mediaUploading = $state(false)
99
+
100
+ const COLORS = [
101
+ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff',
102
+ '#e74c3c', '#c0392b', '#e67e22', '#d35400', '#f1c40f', '#f39c12',
103
+ '#2ecc71', '#27ae60', '#1abc9c', '#16a085', '#3498db', '#2980b9',
104
+ '#9b59b6', '#8e44ad', '#e84393', '#fd79a8', '#a29bfe', '#6c5ce7',
105
+ ]
106
+
107
+ const FONT_SIZES = ['10px', '12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px', '36px', '48px']
108
+
109
+ const HTML_RULES: [RegExp, string][] = [
110
+ [/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="text-gray-600 italic">$1</span>'],
111
+ [/(&lt;\/?)([\w-]+)/g, '<span class="text-gray-500">$1</span><span class="text-green-400">$2</span>'],
112
+ [/([\w-]+)(=)(&quot;[^&]*&quot;|&#39;[^&]*&#39;)/g, '<span class="text-blue-400">$1</span><span class="text-gray-500">$2</span><span class="text-sky-300">$3</span>'],
113
+ [/(&gt;)/g, '<span class="text-gray-500">$1</span>'],
114
+ ]
115
+
116
+ /** Escape string for safe HTML attribute insertion */
117
+ function escAttr(str: string): string {
118
+ return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
119
+ }
120
+
121
+ onMount(() => {
122
+ if (value) editor.innerHTML = value
123
+ updateCounts()
124
+ })
125
+
126
+ // ── Core editor functions ──
127
+ function handleInput() { value = editor.innerHTML; updateCounts() }
128
+
129
+ function updateCounts() {
130
+ const text = editor?.innerText ?? ''
131
+ charCount = text.length
132
+ wordCount = text.trim() ? text.trim().split(/\s+/).length : 0
133
+ }
134
+
135
+ function handleKeydown(e: KeyboardEvent) {
136
+ if (e.ctrlKey || e.metaKey) {
137
+ switch (e.key.toLowerCase()) {
138
+ case 'b': e.preventDefault(); exec('bold'); break
139
+ case 'i': e.preventDefault(); exec('italic'); break
140
+ case 'u': e.preventDefault(); exec('underline'); break
141
+ case 'k': e.preventDefault(); openLinkModal(); break
142
+ case 'h': e.preventDefault(); showFindReplace = !showFindReplace; break
143
+ }
144
+ }
145
+ if (e.key === 'Tab') { e.preventDefault(); exec(e.shiftKey ? 'outdent' : 'indent') }
146
+ }
147
+
148
+ function handlePaste(e: ClipboardEvent) {
149
+ const html = e.clipboardData?.getData('text/html')
150
+ if (html) { e.preventDefault(); exec('insertHTML', cleanHtml(html)) }
151
+ }
152
+
153
+ function cleanHtml(html: string): string {
154
+ const div = document.createElement('div')
155
+ div.innerHTML = html
156
+ div.querySelectorAll('*').forEach(el => {
157
+ el.removeAttribute('class'); el.removeAttribute('id')
158
+ const style = el.getAttribute('style')
159
+ if (style) {
160
+ const parts = style.split(';').filter(s => ['text-align', 'font-weight', 'font-style', 'text-decoration'].some(a => s.trim().startsWith(a)))
161
+ if (parts.length) el.setAttribute('style', parts.join(';')); else el.removeAttribute('style')
162
+ }
163
+ })
164
+ div.querySelectorAll('script, style, meta, link').forEach(el => el.remove())
165
+ return div.innerHTML
166
+ }
167
+
168
+ function exec(command: string, val: string | undefined = undefined) {
169
+ editor?.focus()
170
+ document.execCommand(command, false, val)
171
+ updateActiveFormats()
172
+ value = editor.innerHTML
173
+ updateCounts()
174
+ }
175
+
176
+ function updateActiveFormats() {
177
+ const formats = new Set<string>()
178
+ for (const cmd of ['bold', 'italic', 'underline', 'strikeThrough', 'subscript', 'superscript', 'insertUnorderedList', 'insertOrderedList', 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull']) {
179
+ if (document.queryCommandState(cmd)) formats.add(cmd)
180
+ }
181
+ const block = document.queryCommandValue('formatBlock')
182
+ if (block) formats.add(block.toLowerCase())
183
+ activeFormats = formats
184
+ }
185
+
186
+ function saveSelection() { const sel = window.getSelection(); if (sel && sel.rangeCount > 0) savedRange = sel.getRangeAt(0).cloneRange() }
187
+ function restoreSelection() { if (savedRange) { const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(savedRange) } }
188
+
189
+ // ── Context menu ──
190
+ function handleContextMenu(e: MouseEvent) {
191
+ e.preventDefault()
192
+ const target = e.target as HTMLElement
193
+ contextTarget = target
194
+ const img = target.closest('img') as HTMLImageElement | null
195
+ const link = target.closest('a') as HTMLAnchorElement | null
196
+ const table = target.closest('table') as HTMLTableElement | null
197
+ if (img) { contextType = 'image'; targetImage = img }
198
+ else if (link) contextType = 'link'
199
+ else if (table) { contextType = 'table'; targetTable = table; targetCell = target.closest('td, th') as HTMLTableCellElement }
200
+ else contextType = 'general'
201
+ contextPos = { x: e.clientX, y: e.clientY }
202
+ showContextMenu = true
203
+ }
204
+
205
+ function handleDblClick(e: MouseEvent) {
206
+ const target = e.target as HTMLElement
207
+ if (target.tagName === 'IMG') openImageProps(target as HTMLImageElement)
208
+ else if (target.closest('a')) openLinkModalFromElement(target.closest('a') as HTMLAnchorElement)
209
+ }
210
+
211
+ // ── Image properties ──
212
+ function openImageProps(img: HTMLImageElement) {
213
+ targetImage = img; imageUrl = img.src; imageAlt = img.alt ?? ''; imageTitle = img.title ?? ''
214
+ imageClass = img.className ?? ''; imageStyle = img.getAttribute('style') ?? ''
215
+ imageWidth = img.width ? String(img.width) : ''; imageHeight = img.height ? String(img.height) : ''
216
+ imageAlign = img.style.float || (img.style.display === 'block' && img.style.margin === '0px auto' ? 'center' : '') || ''
217
+ showImagePropsModal = true; showContextMenu = false
218
+ }
219
+
220
+ function applyImageProps() {
221
+ if (!targetImage) return
222
+ targetImage.alt = imageAlt; targetImage.title = imageTitle; targetImage.className = imageClass
223
+ if (imageWidth) targetImage.width = parseInt(imageWidth); else targetImage.removeAttribute('width')
224
+ if (imageHeight) targetImage.height = parseInt(imageHeight); else targetImage.removeAttribute('height')
225
+ targetImage.style.float = ''; targetImage.style.display = ''; targetImage.style.margin = ''
226
+ if (imageAlign === 'left') targetImage.style.float = 'left'
227
+ else if (imageAlign === 'right') targetImage.style.float = 'right'
228
+ else if (imageAlign === 'center') { targetImage.style.display = 'block'; targetImage.style.margin = '0 auto' }
229
+ if (imageStyle) { const existing = targetImage.getAttribute('style') ?? ''; targetImage.setAttribute('style', existing + ';' + imageStyle) }
230
+ showImagePropsModal = false; value = editor.innerHTML
231
+ }
232
+
233
+ function deleteTargetImage() { targetImage?.remove(); showImagePropsModal = false; showContextMenu = false; value = editor.innerHTML }
234
+
235
+ // ── Link ──
236
+ function openLinkModal() {
237
+ saveSelection(); const sel = window.getSelection(); linkText = sel?.toString() ?? ''
238
+ const anchor = sel?.anchorNode?.parentElement?.closest('a')
239
+ linkUrl = anchor?.getAttribute('href') ?? ''; linkTarget = anchor?.getAttribute('target') === '_blank' || !anchor
240
+ linkTitle = anchor?.getAttribute('title') ?? ''; linkClass = anchor?.className ?? ''; showLinkModal = true
241
+ }
242
+
243
+ function openLinkModalFromElement(a: HTMLAnchorElement) {
244
+ linkUrl = a.href; linkText = a.textContent ?? ''; linkTarget = a.target === '_blank'
245
+ linkTitle = a.title ?? ''; linkClass = a.className ?? ''
246
+ const range = document.createRange(); range.selectNodeContents(a)
247
+ const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range)
248
+ saveSelection(); showLinkModal = true; showContextMenu = false
249
+ }
250
+
251
+ function insertLink() {
252
+ showLinkModal = false; restoreSelection(); editor.focus()
253
+ if (!linkUrl) { exec('unlink'); return }
254
+ const safeUrl = escAttr(linkUrl)
255
+ const attrs = `href="${safeUrl}"${linkTarget ? ' target="_blank" rel="noopener"' : ''}${linkTitle ? ` title="${escAttr(linkTitle)}"` : ''}${linkClass ? ` class="${escAttr(linkClass)}"` : ''}`
256
+ if (linkText) exec('insertHTML', `<a ${attrs}>${escAttr(linkText)}</a>`)
257
+ else {
258
+ exec('createLink', linkUrl)
259
+ const a = window.getSelection()?.anchorNode?.parentElement?.closest('a')
260
+ if (a) { if (linkTarget) { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener') }; if (linkTitle) a.setAttribute('title', linkTitle); if (linkClass) a.className = linkClass }
261
+ }
262
+ value = editor.innerHTML
263
+ }
264
+
265
+ // ── Image insert ──
266
+ function openImageModal() { saveSelection(); imageUrl = ''; imageAlt = ''; imageTitle = ''; imageClass = ''; imageWidth = ''; imageHeight = ''; showImageModal = true }
267
+
268
+ async function handleImageUpload(files: FileList | null) {
269
+ if (!files?.length || !media?.upload) return
270
+ imageUploading = true
271
+ try {
272
+ const result = await media.upload(files[0])
273
+ imageUrl = result.url
274
+ imageAlt = files[0].name.replace(/\.[^/.]+$/, '')
275
+ } catch { /* */ } finally { imageUploading = false }
276
+ }
277
+
278
+ function insertImage() {
279
+ showImageModal = false; if (!imageUrl) return
280
+ restoreSelection(); editor.focus()
281
+ const attrs = [`src="${escAttr(imageUrl)}"`, imageAlt ? `alt="${escAttr(imageAlt)}"` : '', imageTitle ? `title="${escAttr(imageTitle)}"` : '', imageClass ? `class="${escAttr(imageClass)}"` : '', imageWidth ? `width="${escAttr(imageWidth)}"` : '', imageHeight ? `height="${escAttr(imageHeight)}"` : '', 'loading="lazy"'].filter(Boolean).join(' ')
282
+ exec('insertHTML', `<img ${attrs} />`)
283
+ value = editor.innerHTML
284
+ }
285
+
286
+ function openMediaExplorerForImage() { mediaExplorerContext = 'imageModal'; showImageModal = false; showMediaExplorer = true; browseMedia() }
287
+ function openMediaExplorerInline() { saveSelection(); mediaExplorerContext = 'editor'; showMediaExplorer = true; browseMedia() }
288
+
289
+ async function browseMedia() {
290
+ if (!media?.browse) return
291
+ mediaLoading = true
292
+ try {
293
+ const result = await media.browse(mediaPage, mediaSearch)
294
+ mediaFiles = result.files; mediaTotal = result.total
295
+ } catch { mediaFiles = [] } finally { mediaLoading = false }
296
+ }
297
+
298
+ function handleMediaSelect(url: string) {
299
+ showMediaExplorer = false
300
+ if (mediaExplorerContext === 'imageModal') {
301
+ imageUrl = url; imageAlt = url.split('/').pop()?.replace(/\.[^/.]+$/, '') ?? ''
302
+ showImageModal = true
303
+ } else {
304
+ restoreSelection(); editor.focus()
305
+ exec('insertHTML', `<img src="${escAttr(url)}" loading="lazy" />`)
306
+ value = editor.innerHTML
307
+ }
308
+ }
309
+
310
+ // ── Table ──
311
+ function openTableModal() { saveSelection(); tableRows = 3; tableCols = 3; showTableModal = true }
312
+
313
+ function insertTable() {
314
+ showTableModal = false; restoreSelection(); editor.focus()
315
+ let html = '<table><thead><tr>'
316
+ for (let c = 0; c < tableCols; c++) html += '<th>En-tête</th>'
317
+ html += '</tr></thead><tbody>'
318
+ for (let r = 0; r < tableRows - 1; r++) { html += '<tr>'; for (let c = 0; c < tableCols; c++) html += '<td>&nbsp;</td>'; html += '</tr>' }
319
+ html += '</tbody></table><p><br></p>'
320
+ exec('insertHTML', html)
321
+ }
322
+
323
+ function tableAction(action: string) {
324
+ showContextMenu = false
325
+ if (!targetTable || !targetCell) return
326
+ const row = targetCell.parentElement as HTMLTableRowElement
327
+ const rowIndex = row.rowIndex; const colIndex = targetCell.cellIndex
328
+ if (action === 'addRowAbove') { const nr = targetTable.insertRow(rowIndex); for (let i = 0; i < row.cells.length; i++) nr.insertCell().innerHTML = '&nbsp;' }
329
+ else if (action === 'addRowBelow') { const nr = targetTable.insertRow(rowIndex + 1); for (let i = 0; i < row.cells.length; i++) nr.insertCell().innerHTML = '&nbsp;' }
330
+ else if (action === 'addColLeft' || action === 'addColRight') { const idx = action === 'addColLeft' ? colIndex : colIndex + 1; for (let r = 0; r < targetTable.rows.length; r++) { const cell = targetTable.rows[r].insertCell(idx); cell.innerHTML = r === 0 && targetTable.tHead ? 'En-tête' : '&nbsp;' } }
331
+ else if (action === 'deleteRow') { if (targetTable.rows.length > 1) targetTable.deleteRow(rowIndex) }
332
+ else if (action === 'deleteCol') { for (let r = targetTable.rows.length - 1; r >= 0; r--) { if (targetTable.rows[r].cells.length > 1) targetTable.rows[r].deleteCell(colIndex) } }
333
+ else if (action === 'deleteTable') targetTable.remove()
334
+ value = editor.innerHTML
335
+ }
336
+
337
+ // ── Embed ──
338
+ function openEmbedModal() { saveSelection(); embedUrl = ''; showEmbedModal = true }
339
+
340
+ function insertEmbed() {
341
+ showEmbedModal = false; if (!embedUrl) return
342
+ restoreSelection(); editor.focus()
343
+ let match = embedUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/)
344
+ if (match) { exec('insertHTML', `<div class="embed-responsive"><iframe src="https://www.youtube.com/embed/${escAttr(match[1])}" frameborder="0" allowfullscreen loading="lazy"></iframe></div><p><br></p>`); value = editor.innerHTML; return }
345
+ match = embedUrl.match(/vimeo\.com\/(\d+)/)
346
+ if (match) { exec('insertHTML', `<div class="embed-responsive"><iframe src="https://player.vimeo.com/video/${escAttr(match[1])}" frameborder="0" allowfullscreen loading="lazy"></iframe></div><p><br></p>`); value = editor.innerHTML; return }
347
+ exec('insertHTML', `<div class="embed-responsive"><iframe src="${escAttr(embedUrl)}" frameborder="0" loading="lazy"></iframe></div><p><br></p>`)
348
+ value = editor.innerHTML
349
+ }
350
+
351
+ // ── Find & Replace ──
352
+ function findNext() {
353
+ if (!findText) return
354
+ const sel = window.getSelection(); const range = document.createRange()
355
+ const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT)
356
+ let node: Node | null; let found = false
357
+ while ((node = walker.nextNode())) {
358
+ const idx = (node.textContent ?? '').toLowerCase().indexOf(findText.toLowerCase())
359
+ if (idx >= 0) { range.setStart(node, idx); range.setEnd(node, idx + findText.length); sel?.removeAllRanges(); sel?.addRange(range); found = true; break }
360
+ }
361
+ }
362
+
363
+ function replaceNext() { const sel = window.getSelection(); if (sel?.toString().toLowerCase() === findText.toLowerCase()) exec('insertText', replaceText); findNext() }
364
+ function replaceAll() { if (!findText) return; editor.innerHTML = editor.innerHTML.replaceAll(findText, replaceText); value = editor.innerHTML }
365
+
366
+ // ── Element properties ──
367
+ function openElementProps(el?: HTMLElement) {
368
+ const target = el ?? contextTarget; if (!target || target === editor) return
369
+ elPropsTarget = target; elPropsTag = target.tagName.toLowerCase()
370
+ elPropsClass = target.className ?? ''; elPropsStyle = target.getAttribute('style') ?? ''; elPropsId = target.id ?? ''
371
+ showElementProps = true; showContextMenu = false
372
+ }
373
+
374
+ function applyElementProps() {
375
+ if (!elPropsTarget) return
376
+ elPropsTarget.className = elPropsClass
377
+ if (elPropsStyle) elPropsTarget.setAttribute('style', elPropsStyle); else elPropsTarget.removeAttribute('style')
378
+ if (elPropsId) elPropsTarget.id = elPropsId; else elPropsTarget.removeAttribute('id')
379
+ showElementProps = false; value = editor.innerHTML
380
+ }
381
+
382
+ // ── Font size ──
383
+ function setFontSize(size: string) {
384
+ exec('fontSize', '7')
385
+ editor.querySelectorAll('font[size="7"]').forEach(el => {
386
+ const span = document.createElement('span'); span.style.fontSize = size; span.innerHTML = el.innerHTML; el.replaceWith(span)
387
+ })
388
+ value = editor.innerHTML
389
+ }
390
+
391
+ // ── Source mode ──
392
+ function toggleSource() {
393
+ if (!sourceMode) { sourceCode = formatHtml(editor.innerHTML); sourceMode = true; tick().then(() => updateLineNumbers()) }
394
+ else { sourceMode = false; tick().then(() => { editor.innerHTML = sourceCode; value = sourceCode; updateCounts() }) }
395
+ }
396
+
397
+ function formatHtml(html: string): string {
398
+ let result = '', indent = 0
399
+ const tags = html.replace(/>\s*</g, '>\n<').split('\n')
400
+ for (const tag of tags) {
401
+ const trimmed = tag.trim(); if (!trimmed) continue
402
+ if (trimmed.startsWith('</')) indent = Math.max(0, indent - 1)
403
+ result += ' '.repeat(indent) + trimmed + '\n'
404
+ if (trimmed.startsWith('<') && !trimmed.startsWith('</') && !trimmed.endsWith('/>') && !trimmed.startsWith('<!') && !/^<(br|hr|img|input|meta|link)\b/i.test(trimmed)) indent++
405
+ }
406
+ return result.trim()
407
+ }
408
+
409
+ function highlightHtml(code: string): string {
410
+ let escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
411
+ for (const [regex, replacement] of HTML_RULES) escaped = escaped.replace(regex, replacement)
412
+ return escaped
413
+ }
414
+
415
+ function updateLineNumbers() {
416
+ if (!sourceEl || !lineNumbers) return
417
+ lineNumbers.innerHTML = Array.from({ length: sourceCode.split('\n').length }, (_, i) => `<div>${i + 1}</div>`).join('')
418
+ }
419
+
420
+ function handleSourceInput() { value = sourceCode; updateLineNumbers() }
421
+ function handleSourceScroll() { if (lineNumbers && sourceEl) lineNumbers.scrollTop = sourceEl.scrollTop }
422
+ function insertColor(color: string) { exec('foreColor', color); showColorPicker = false }
423
+ function isActive(cmd: string): string { return activeFormats.has(cmd) ? 'bg-violet-500/15 text-violet-400' : 'text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]' }
424
+
425
+ const btnClass = 'flex items-center justify-center min-w-[32px] h-8 rounded-md transition-all cursor-pointer border-none bg-transparent px-1'
426
+ const sepClass = 'w-px h-[22px] bg-[var(--karbon-border)] mx-0.5 shrink-0'
427
+ const modalOverlay = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm'
428
+ const modalBox = 'w-full rounded-xl border border-[var(--karbon-border)] bg-[var(--karbon-bg-card)] p-6 shadow-2xl'
429
+ const modalInput = 'block w-full rounded-lg border border-[var(--karbon-border-input)] bg-[var(--karbon-bg-input)] text-[var(--karbon-text)] px-3 py-2 text-sm outline-none focus:border-[var(--karbon-border-input-focus)] transition-colors'
430
+ const modalLabel = 'block text-xs font-medium text-[var(--karbon-text-4)]'
431
+ const modalBtnCancel = 'rounded-lg border border-[var(--karbon-border)] px-3 py-1.5 text-xs text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)]'
432
+ const modalBtnPrimary = 'rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-40'
433
+ </script>
434
+ <div role="toolbar" aria-label="Éditeur de texte riche" class="rounded-xl border border-[var(--karbon-border)] bg-[var(--karbon-bg-card)] shadow-sm overflow-hidden {fullscreen ? 'fixed inset-0 z-40 rounded-none flex flex-col' : ''} {className}">
435
+
436
+ <!-- ═══ TOOLBAR ═══ -->
437
+ <div class="flex flex-wrap items-center gap-1 border-b border-[var(--karbon-border)] bg-[var(--karbon-bg-2)]/50 px-3 py-2 select-none">
438
+ <button type="button" onclick={() => exec('undo')} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Annuler (Ctrl+Z)" aria-label="Annuler">
439
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
440
+ </button>
441
+ <button type="button" onclick={() => exec('redo')} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Rétablir" aria-label="Rétablir">
442
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/></svg>
443
+ </button>
444
+ <span class={sepClass}></span>
445
+
446
+ <button type="button" onclick={() => exec('formatBlock', 'p')} class="{btnClass} {isActive('p')}" title="Paragraphe" aria-label="Paragraphe">
447
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 4v16"/><path d="M17 4v16"/><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13"/></svg>
448
+ </button>
449
+ <button type="button" onclick={() => exec('formatBlock', 'h2')} class="{btnClass} text-xs font-bold {isActive('h2')}" title="Titre 2">H2</button>
450
+ <button type="button" onclick={() => exec('formatBlock', 'h3')} class="{btnClass} text-xs font-bold {isActive('h3')}" title="Titre 3">H3</button>
451
+ <button type="button" onclick={() => exec('formatBlock', 'h4')} class="{btnClass} text-xs font-bold {isActive('h4')}" title="Titre 4">H4</button>
452
+ <span class={sepClass}></span>
453
+
454
+ <select onchange={(e) => setFontSize(e.currentTarget.value)} class="bg-[var(--karbon-bg-input)] border border-[var(--karbon-border-input)] text-[var(--karbon-text-3)] rounded-[5px] px-1 py-0.5 text-[0.6875rem] outline-none cursor-pointer" title="Taille">
455
+ <option value="">Taille</option>
456
+ {#each FONT_SIZES as size}<option value={size}>{size}</option>{/each}
457
+ </select>
458
+ <span class={sepClass}></span>
459
+
460
+ <button type="button" onclick={() => exec('bold')} class="{btnClass} {isActive('bold')}" title="Gras (Ctrl+B)" aria-label="Gras">
461
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></svg>
462
+ </button>
463
+ <button type="button" onclick={() => exec('italic')} class="{btnClass} {isActive('italic')}" title="Italique (Ctrl+I)" aria-label="Italique">
464
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>
465
+ </button>
466
+ <button type="button" onclick={() => exec('underline')} class="{btnClass} {isActive('underline')}" title="Souligné (Ctrl+U)" aria-label="Souligné">
467
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>
468
+ </button>
469
+ <button type="button" onclick={() => exec('strikeThrough')} class="{btnClass} {isActive('strikeThrough')}" title="Barré" aria-label="Barré">
470
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>
471
+ </button>
472
+ <span class={sepClass}></span>
473
+
474
+ <div class="relative">
475
+ <button type="button" onclick={() => showColorPicker = !showColorPicker} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Couleur" aria-label="Couleur du texte">
476
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 11-8-8-8.6 8.6a2 2 0 0 0 0 2.8l5.2 5.2c.8.8 2 .8 2.8 0L19 11Z"/><path d="m5 2 5 5"/><path d="M2 13h15"/><path d="M22 20a2 2 0 1 1-4 0c0-1.6 1.7-2.4 2-4 .3 1.6 2 2.4 2 4Z"/></svg>
477
+ </button>
478
+ {#if showColorPicker}
479
+ <div class="absolute left-0 top-full z-20 mt-1 grid grid-cols-8 gap-1 rounded-lg border border-[var(--karbon-border)] bg-[var(--karbon-bg-card)] p-2 shadow-xl">
480
+ {#each COLORS as color}<button type="button" onclick={() => insertColor(color)} class="h-5 w-5 rounded border border-[var(--karbon-border)] hover:scale-125 transition-transform cursor-pointer" style="background-color: {color}" aria-label="Couleur {color}"></button>{/each}
481
+ </div>
482
+ {/if}
483
+ </div>
484
+ <button type="button" onclick={() => exec('removeFormat')} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Effacer formatage" aria-label="Effacer le formatage">
485
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 21a4.6 4.6 0 0 1 0-9h10a4.6 4.6 0 1 1 0 9H7Z"/><path d="m3 3 18 18"/></svg>
486
+ </button>
487
+ <span class={sepClass}></span>
488
+
489
+ <button type="button" onclick={() => exec('justifyLeft')} class="{btnClass} {isActive('justifyLeft')}" title="Gauche" aria-label="Aligner à gauche">
490
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="21" x2="3" y1="6" y2="6"/><line x1="15" x2="3" y1="12" y2="12"/><line x1="17" x2="3" y1="18" y2="18"/></svg>
491
+ </button>
492
+ <button type="button" onclick={() => exec('justifyCenter')} class="{btnClass} {isActive('justifyCenter')}" title="Centre" aria-label="Centrer">
493
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="21" x2="3" y1="6" y2="6"/><line x1="17" x2="7" y1="12" y2="12"/><line x1="19" x2="5" y1="18" y2="18"/></svg>
494
+ </button>
495
+ <button type="button" onclick={() => exec('justifyRight')} class="{btnClass} {isActive('justifyRight')}" title="Droite" aria-label="Aligner à droite">
496
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="21" x2="3" y1="6" y2="6"/><line x1="21" x2="9" y1="12" y2="12"/><line x1="21" x2="7" y1="18" y2="18"/></svg>
497
+ </button>
498
+ <span class={sepClass}></span>
499
+
500
+ <button type="button" onclick={() => exec('insertUnorderedList')} class="{btnClass} {isActive('insertunorderedlist')}" title="Puces" aria-label="Liste à puces">
501
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>
502
+ </button>
503
+ <button type="button" onclick={() => exec('insertOrderedList')} class="{btnClass} {isActive('insertorderedlist')}" title="Numéros" aria-label="Liste numérotée">
504
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>
505
+ </button>
506
+ <button type="button" onclick={() => exec('formatBlock', 'blockquote')} class="{btnClass} {isActive('blockquote')}" title="Citation" aria-label="Citation">
507
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z"/></svg>
508
+ </button>
509
+ <button type="button" onclick={() => exec('insertHTML', '<hr />')} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Séparateur" aria-label="Séparateur">
510
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>
511
+ </button>
512
+ <span class={sepClass}></span>
513
+
514
+ <button type="button" onclick={openLinkModal} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Lien (Ctrl+K)" aria-label="Insérer un lien">
515
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
516
+ </button>
517
+ <button type="button" onclick={openImageModal} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Image" aria-label="Insérer une image">
518
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
519
+ </button>
520
+ {#if media}
521
+ <button type="button" onclick={openMediaExplorerInline} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Médias" aria-label="Explorer les médias">
522
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>
523
+ </button>
524
+ {/if}
525
+ <button type="button" onclick={openTableModal} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Tableau" aria-label="Insérer un tableau">
526
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>
527
+ </button>
528
+ <button type="button" onclick={openEmbedModal} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Vidéo / Embed" aria-label="Insérer une vidéo">
529
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17"/><path d="m10 15 5-3-5-3z"/></svg>
530
+ </button>
531
+
532
+ <div class="flex-1"></div>
533
+
534
+ <button type="button" onclick={() => showFindReplace = !showFindReplace} class="{btnClass} {showFindReplace ? 'bg-violet-500/15 text-violet-400' : 'text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]'}" title="Rechercher (Ctrl+H)" aria-label="Rechercher et remplacer">
535
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
536
+ </button>
537
+ <button type="button" onclick={toggleSource} class="{btnClass} {sourceMode ? 'bg-violet-500/15 text-violet-400' : 'text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]'}" title="Source" aria-label="Mode source HTML">
538
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/></svg>
539
+ </button>
540
+ <button type="button" onclick={() => fullscreen = !fullscreen} class="{btnClass} text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" title="Plein écran" aria-label="Plein écran">
541
+ {#if fullscreen}
542
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/></svg>
543
+ {:else}
544
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/></svg>
545
+ {/if}
546
+ </button>
547
+ </div>
548
+
549
+ <!-- ═══ FIND/REPLACE BAR ═══ -->
550
+ {#if showFindReplace}
551
+ <div class="flex items-center gap-2 border-b border-[var(--karbon-border)] bg-[var(--karbon-bg-2)]/30 px-4 py-2">
552
+ <input type="text" bind:value={findText} placeholder="Rechercher..." class="{modalInput} w-40 !py-1 !px-2 !text-xs" onkeydown={(e) => { if (e.key === 'Enter') findNext() }} />
553
+ <input type="text" bind:value={replaceText} placeholder="Remplacer..." class="{modalInput} w-40 !py-1 !px-2 !text-xs" />
554
+ <button type="button" onclick={findNext} class="bg-[var(--karbon-bg-2)] border border-[var(--karbon-border)] text-[var(--karbon-text-3)] rounded-md px-2.5 py-1 text-[0.6875rem] cursor-pointer hover:bg-[var(--karbon-bg-card)] hover:text-[var(--karbon-text)] transition-all">Suivant</button>
555
+ <button type="button" onclick={replaceNext} class="bg-[var(--karbon-bg-2)] border border-[var(--karbon-border)] text-[var(--karbon-text-3)] rounded-md px-2.5 py-1 text-[0.6875rem] cursor-pointer hover:bg-[var(--karbon-bg-card)] hover:text-[var(--karbon-text)] transition-all">Remplacer</button>
556
+ <button type="button" onclick={replaceAll} class="bg-[var(--karbon-bg-2)] border border-[var(--karbon-border)] text-[var(--karbon-text-3)] rounded-md px-2.5 py-1 text-[0.6875rem] cursor-pointer hover:bg-[var(--karbon-bg-card)] hover:text-[var(--karbon-text)] transition-all">Tout</button>
557
+ <button type="button" onclick={() => showFindReplace = false} class="{btnClass} text-[var(--karbon-text-4)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)]" aria-label="Fermer">
558
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
559
+ </button>
560
+ </div>
561
+ {/if}
562
+
563
+ <!-- ═══ EDITOR / SOURCE ═══ -->
564
+ {#if sourceMode}
565
+ <div class="relative flex overflow-hidden bg-[#0d1117] {fullscreen ? 'flex-1' : 'h-[500px]'}">
566
+ <div bind:this={lineNumbers} class="shrink-0 w-12 py-4 text-right font-mono text-[0.8125rem] leading-[1.625] text-[#484f58] select-none overflow-hidden border-r border-[#21262d] [&>div]:pr-3"></div>
567
+ <div class="absolute top-0 left-12 right-0 bottom-0 p-4 font-mono text-[0.8125rem] leading-[1.625] whitespace-pre overflow-hidden pointer-events-none text-[#c9d1d9]">{@html highlightHtml(sourceCode)}</div>
568
+ <textarea bind:this={sourceEl} bind:value={sourceCode} oninput={handleSourceInput} onscroll={handleSourceScroll} class="absolute top-0 left-12 right-0 bottom-0 p-4 m-0 border-none outline-none resize-none font-mono text-[0.8125rem] leading-[1.625] text-transparent caret-[#c9d1d9] bg-transparent whitespace-pre overflow-auto [-webkit-text-fill-color:transparent]" spellcheck="false" wrap="off"></textarea>
569
+ </div>
570
+ {:else}
571
+ <div
572
+ bind:this={editor} contenteditable="true"
573
+ class="outline-none px-5 py-5 text-[var(--karbon-text)] text-[0.9375rem] leading-relaxed
574
+ [&:empty:before]:content-[attr(data-placeholder)] [&:empty:before]:text-[var(--karbon-text-4)] [&:empty:before]:pointer-events-none
575
+ [&_h2]:text-[1.5em] [&_h2]:font-bold [&_h2]:mt-4 [&_h2]:mb-2
576
+ [&_h3]:text-[1.25em] [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1.5
577
+ [&_h4]:text-[1.1em] [&_h4]:font-semibold [&_h4]:mt-2.5 [&_h4]:mb-1
578
+ [&_p]:my-2 [&_a]:text-violet-500 [&_a]:underline
579
+ [&_blockquote]:border-l-[3px] [&_blockquote]:border-violet-500 [&_blockquote]:pl-4 [&_blockquote]:py-2 [&_blockquote]:my-4 [&_blockquote]:bg-violet-500/5 [&_blockquote]:rounded-r-lg [&_blockquote]:italic
580
+ [&_pre]:bg-[var(--karbon-bg-2)] [&_pre]:border [&_pre]:border-[var(--karbon-border)] [&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:font-mono [&_pre]:text-[0.875em] [&_pre]:overflow-x-auto [&_pre]:my-4
581
+ [&_code]:bg-[var(--karbon-bg-2)] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.875em]
582
+ [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-4 [&_img]:cursor-pointer [&_img:hover]:outline [&_img:hover]:outline-2 [&_img:hover]:outline-violet-500/50 [&_img:hover]:outline-offset-2
583
+ [&_hr]:border-none [&_hr]:border-t [&_hr]:border-[var(--karbon-border)] [&_hr]:my-6
584
+ [&_ul]:pl-6 [&_ul]:my-2 [&_ol]:pl-6 [&_ol]:my-2 [&_li]:my-1
585
+ [&_table]:w-full [&_table]:border-collapse [&_table]:my-4
586
+ [&_th]:border [&_th]:border-[var(--karbon-border)] [&_th]:px-3 [&_th]:py-2 [&_th]:bg-[var(--karbon-bg-2)] [&_th]:font-semibold [&_th]:text-[0.875em]
587
+ [&_td]:border [&_td]:border-[var(--karbon-border)] [&_td]:px-3 [&_td]:py-2
588
+ [&_.embed-responsive]:relative [&_.embed-responsive]:pb-[56.25%] [&_.embed-responsive]:h-0 [&_.embed-responsive]:overflow-hidden [&_.embed-responsive]:my-4 [&_.embed-responsive]:rounded-lg
589
+ [&_.embed-responsive_iframe]:absolute [&_.embed-responsive_iframe]:inset-0 [&_.embed-responsive_iframe]:w-full [&_.embed-responsive_iframe]:h-full [&_.embed-responsive_iframe]:border-0
590
+ {fullscreen ? 'flex-1 overflow-y-auto' : 'min-h-[500px] max-h-[800px] overflow-y-auto'}"
591
+ data-placeholder={placeholder} role="textbox" tabindex="0" aria-multiline="true"
592
+ oninput={handleInput} onkeydown={handleKeydown} onkeyup={updateActiveFormats}
593
+ onmouseup={updateActiveFormats} onpaste={handlePaste}
594
+ oncontextmenu={handleContextMenu} ondblclick={handleDblClick}
595
+ ></div>
596
+ {/if}
597
+
598
+ <!-- ═══ STATUS BAR ═══ -->
599
+ <div class="flex items-center gap-4 border-t border-[var(--karbon-border)] bg-[var(--karbon-bg-2)]/30 px-4 py-1.5 text-[11px] text-[var(--karbon-text-4)]">
600
+ <span>{wordCount} mot{wordCount !== 1 ? 's' : ''}</span>
601
+ <span>{charCount} caractère{charCount !== 1 ? 's' : ''}</span>
602
+ {#if sourceMode}<span class="text-violet-400">Mode source</span>{/if}
603
+ <div class="flex-1"></div>
604
+ <span>Double-clic : propriétés · Clic droit : menu contextuel</span>
605
+ </div>
606
+ </div>
607
+
608
+ <!-- ═══ CONTEXT MENU ═══ -->
609
+ {#if showContextMenu}
610
+ <div class="fixed inset-0 z-[60]" role="presentation" onclick={() => showContextMenu = false} onkeydown={(e) => { if (e.key === "Escape") showContextMenu = false }}></div>
611
+ <div class="fixed z-[61] w-56 rounded-xl border border-[var(--karbon-border)] bg-[var(--karbon-bg-card)] shadow-2xl shadow-black/30 backdrop-blur-xl overflow-hidden" style="left: {contextPos.x}px; top: {contextPos.y}px;">
612
+ <div class="px-3 py-2 border-b border-[var(--karbon-border)] bg-[var(--karbon-bg-2)]/50">
613
+ <span class="text-[11px] font-semibold uppercase tracking-wider text-[var(--karbon-text-4)]">
614
+ {contextType === 'image' ? 'Image' : contextType === 'link' ? 'Lien' : contextType === 'table' ? 'Tableau' : 'Élément'}
615
+ </span>
616
+ </div>
617
+ {#if contextType === 'image'}
618
+ <div class="py-1">
619
+ <button type="button" onclick={() => { if (targetImage) openImageProps(targetImage) }} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-[var(--karbon-text-2)] bg-transparent border-none cursor-pointer transition-all hover:bg-violet-500/8 hover:text-[var(--karbon-text)]">Propriétés de l'image</button>
620
+ </div>
621
+ <div class="border-t border-[var(--karbon-border)] py-1">
622
+ <button type="button" onclick={deleteTargetImage} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-red-400 bg-transparent border-none cursor-pointer transition-all hover:bg-red-500/10 hover:text-red-500">Supprimer l'image</button>
623
+ </div>
624
+ {:else if contextType === 'link'}
625
+ <div class="py-1">
626
+ <button type="button" onclick={() => { const a = contextTarget?.closest('a'); if (a) openLinkModalFromElement(a) }} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-[var(--karbon-text-2)] bg-transparent border-none cursor-pointer transition-all hover:bg-violet-500/8 hover:text-[var(--karbon-text)]">Modifier le lien</button>
627
+ </div>
628
+ <div class="border-t border-[var(--karbon-border)] py-1">
629
+ <button type="button" onclick={() => { showContextMenu = false; exec('unlink') }} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-red-400 bg-transparent border-none cursor-pointer transition-all hover:bg-red-500/10 hover:text-red-500">Supprimer le lien</button>
630
+ </div>
631
+ {:else if contextType === 'table'}
632
+ <div class="py-1">
633
+ <div class="px-3 py-1"><span class="text-[10px] font-semibold uppercase tracking-wider text-[var(--karbon-text-4)]">Lignes</span></div>
634
+ <button type="button" onclick={() => tableAction('addRowAbove')} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-[var(--karbon-text-2)] bg-transparent border-none cursor-pointer transition-all hover:bg-violet-500/8 hover:text-[var(--karbon-text)]">↑ Insérer au-dessus</button>
635
+ <button type="button" onclick={() => tableAction('addRowBelow')} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-[var(--karbon-text-2)] bg-transparent border-none cursor-pointer transition-all hover:bg-violet-500/8 hover:text-[var(--karbon-text)]">↓ Insérer en-dessous</button>
636
+ </div>
637
+ <div class="border-t border-[var(--karbon-border)] py-1">
638
+ <div class="px-3 py-1"><span class="text-[10px] font-semibold uppercase tracking-wider text-[var(--karbon-text-4)]">Colonnes</span></div>
639
+ <button type="button" onclick={() => tableAction('addColLeft')} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-[var(--karbon-text-2)] bg-transparent border-none cursor-pointer transition-all hover:bg-violet-500/8 hover:text-[var(--karbon-text)]">← Insérer à gauche</button>
640
+ <button type="button" onclick={() => tableAction('addColRight')} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-[var(--karbon-text-2)] bg-transparent border-none cursor-pointer transition-all hover:bg-violet-500/8 hover:text-[var(--karbon-text)]">→ Insérer à droite</button>
641
+ </div>
642
+ <div class="border-t border-[var(--karbon-border)] py-1">
643
+ <button type="button" onclick={() => tableAction('deleteRow')} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-amber-400 bg-transparent border-none cursor-pointer transition-all hover:bg-amber-500/10 hover:text-amber-500">Supprimer la ligne</button>
644
+ <button type="button" onclick={() => tableAction('deleteCol')} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-amber-400 bg-transparent border-none cursor-pointer transition-all hover:bg-amber-500/10 hover:text-amber-500">Supprimer la colonne</button>
645
+ <button type="button" onclick={() => tableAction('deleteTable')} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-red-400 bg-transparent border-none cursor-pointer transition-all hover:bg-red-500/10 hover:text-red-500">Supprimer le tableau</button>
646
+ </div>
647
+ {/if}
648
+ <div class="border-t border-[var(--karbon-border)] py-1">
649
+ <button type="button" onclick={() => openElementProps()} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-[var(--karbon-text-2)] bg-transparent border-none cursor-pointer transition-all hover:bg-violet-500/8 hover:text-[var(--karbon-text)]">Propriétés HTML</button>
650
+ <button type="button" onclick={() => { showContextMenu = false; exec('removeFormat') }} class="flex items-center gap-2 w-full text-left px-3 py-[7px] text-xs text-[var(--karbon-text-2)] bg-transparent border-none cursor-pointer transition-all hover:bg-violet-500/8 hover:text-[var(--karbon-text)]">Effacer le formatage</button>
651
+ </div>
652
+ </div>
653
+ {/if}
654
+
655
+ <!-- ═══ LINK MODAL ═══ -->
656
+ {#if showLinkModal}
657
+ <div class={modalOverlay} role="presentation" onclick={() => showLinkModal = false} onkeydown={(e) => { if (e.key === "Escape") showLinkModal = false }}>
658
+ <div class="{modalBox} max-w-md" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
659
+ <div class="flex items-center justify-between mb-4">
660
+ <h3 class="text-sm font-semibold text-[var(--karbon-text)]">Insérer / Modifier un lien</h3>
661
+ <button type="button" onclick={() => showLinkModal = false} class="text-[var(--karbon-text-4)] hover:text-[var(--karbon-text)] cursor-pointer" aria-label="Fermer"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
662
+ </div>
663
+ <div class="space-y-3">
664
+ <div class="space-y-1"><span class={modalLabel}>URL</span><input type="url" bind:value={linkUrl} placeholder="https://..." class={modalInput} /></div>
665
+ <div class="space-y-1"><span class={modalLabel}>Texte</span><input type="text" bind:value={linkText} placeholder="Texte affiché" class={modalInput} /></div>
666
+ <div class="space-y-1"><span class={modalLabel}>Title</span><input type="text" bind:value={linkTitle} placeholder="Info-bulle au survol" class={modalInput} /></div>
667
+ <div class="space-y-1"><span class={modalLabel}>Classes CSS</span><input type="text" bind:value={linkClass} placeholder="ex: btn btn-primary" class="{modalInput} font-mono text-xs" /></div>
668
+ <label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" bind:checked={linkTarget} class="h-4 w-4 rounded" /><span class="text-sm text-[var(--karbon-text-2)]">Ouvrir dans un nouvel onglet</span></label>
669
+ </div>
670
+ <div class="mt-5 flex items-center justify-between">
671
+ <button type="button" onclick={() => { showLinkModal = false; restoreSelection(); exec('unlink') }} class="text-xs text-red-400 hover:text-red-300 cursor-pointer">Supprimer</button>
672
+ <div class="flex gap-2">
673
+ <button type="button" onclick={() => showLinkModal = false} class="{modalBtnCancel} cursor-pointer">Annuler</button>
674
+ <button type="button" onclick={insertLink} class="{modalBtnPrimary} cursor-pointer">Appliquer</button>
675
+ </div>
676
+ </div>
677
+ </div>
678
+ </div>
679
+ {/if}
680
+
681
+ <!-- ═══ IMAGE INSERT MODAL ═══ -->
682
+ {#if showImageModal}
683
+ <div class={modalOverlay} role="presentation" onclick={() => showImageModal = false} onkeydown={(e) => { if (e.key === "Escape") showImageModal = false }}>
684
+ <div class="{modalBox} max-w-lg" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
685
+ <div class="flex items-center justify-between mb-4">
686
+ <h3 class="text-sm font-semibold text-[var(--karbon-text)]">Insérer une image</h3>
687
+ <button type="button" onclick={() => showImageModal = false} class="text-[var(--karbon-text-4)] hover:text-[var(--karbon-text)] cursor-pointer" aria-label="Fermer"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
688
+ </div>
689
+ <div class="space-y-4">
690
+ <div class="grid grid-cols-2 gap-3">
691
+ {#if media?.upload}
692
+ <div class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-[var(--karbon-border)] p-4 hover:border-[var(--karbon-border-input)] transition-colors">
693
+ {#if imageUploading}
694
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin text-violet-400 mb-1"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
695
+ <p class="text-[11px] text-[var(--karbon-text-3)]">Upload...</p>
696
+ {:else}
697
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--karbon-text-4)] mb-1"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
698
+ <label class="cursor-pointer text-xs font-medium text-violet-400 hover:text-violet-300">Uploader<input type="file" accept="image/*" class="hidden" onchange={(e) => handleImageUpload(e.currentTarget.files)} /></label>
699
+ {/if}
700
+ </div>
701
+ {/if}
702
+ {#if media}
703
+ <button type="button" onclick={openMediaExplorerForImage} class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-[var(--karbon-border)] p-4 hover:border-violet-500/40 hover:bg-violet-500/5 transition-colors cursor-pointer">
704
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--karbon-text-4)] mb-1"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>
705
+ <span class="text-xs font-medium text-violet-400">Parcourir les médias</span>
706
+ </button>
707
+ {/if}
708
+ </div>
709
+ <div class="space-y-1"><span class={modalLabel}>URL</span><input type="url" bind:value={imageUrl} placeholder="https://..." class={modalInput} /></div>
710
+ <div class="grid grid-cols-2 gap-3">
711
+ <div class="space-y-1"><span class={modalLabel}>Alt</span><input type="text" bind:value={imageAlt} class={modalInput} /></div>
712
+ <div class="space-y-1"><span class={modalLabel}>Title</span><input type="text" bind:value={imageTitle} class={modalInput} /></div>
713
+ </div>
714
+ <div class="grid grid-cols-2 gap-3">
715
+ <div class="space-y-1"><span class={modalLabel}>Largeur</span><input type="text" bind:value={imageWidth} placeholder="auto" class={modalInput} /></div>
716
+ <div class="space-y-1"><span class={modalLabel}>Classes CSS</span><input type="text" bind:value={imageClass} placeholder="rounded shadow" class="{modalInput} font-mono text-xs" /></div>
717
+ </div>
718
+ {#if imageUrl}<div class="rounded-lg border border-[var(--karbon-border)] overflow-hidden"><img src={imageUrl} alt={imageAlt} class="w-full max-h-40 object-contain bg-[var(--karbon-bg-2)]" /></div>{/if}
719
+ </div>
720
+ <div class="mt-5 flex justify-end gap-2">
721
+ <button type="button" onclick={() => showImageModal = false} class="{modalBtnCancel} cursor-pointer">Annuler</button>
722
+ <button type="button" onclick={insertImage} disabled={!imageUrl} class="{modalBtnPrimary} cursor-pointer">Insérer</button>
723
+ </div>
724
+ </div>
725
+ </div>
726
+ {/if}
727
+
728
+ <!-- ═══ IMAGE PROPS MODAL ═══ -->
729
+ {#if showImagePropsModal}
730
+ <div class={modalOverlay} role="presentation" onclick={() => showImagePropsModal = false} onkeydown={(e) => { if (e.key === "Escape") showImagePropsModal = false }}>
731
+ <div class="{modalBox} max-w-lg" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
732
+ <div class="flex items-center justify-between mb-4">
733
+ <h3 class="text-sm font-semibold text-[var(--karbon-text)]">Propriétés de l'image</h3>
734
+ <button type="button" onclick={() => showImagePropsModal = false} class="text-[var(--karbon-text-4)] hover:text-[var(--karbon-text)] cursor-pointer" aria-label="Fermer"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
735
+ </div>
736
+ {#if imageUrl}<div class="mb-4 rounded-lg border border-[var(--karbon-border)] overflow-hidden bg-[var(--karbon-bg-2)]"><img src={imageUrl} alt="" class="mx-auto max-h-32 object-contain" /></div>{/if}
737
+ <div class="space-y-3">
738
+ <div class="grid grid-cols-2 gap-3">
739
+ <div class="space-y-1"><span class={modalLabel}>Largeur</span><input type="text" bind:value={imageWidth} placeholder="auto" class={modalInput} /></div>
740
+ <div class="space-y-1"><span class={modalLabel}>Hauteur</span><input type="text" bind:value={imageHeight} placeholder="auto" class={modalInput} /></div>
741
+ </div>
742
+ <div class="space-y-1"><span class={modalLabel}>Texte alternatif</span><input type="text" bind:value={imageAlt} class={modalInput} /></div>
743
+ <div class="space-y-1"><span class={modalLabel}>Title</span><input type="text" bind:value={imageTitle} class={modalInput} /></div>
744
+ <div class="space-y-1">
745
+ <span class={modalLabel}>Alignement</span>
746
+ <div class="flex gap-1">
747
+ {#each [{ v: '', l: 'Aucun' }, { v: 'left', l: 'Gauche' }, { v: 'center', l: 'Centre' }, { v: 'right', l: 'Droite' }] as opt}
748
+ <button type="button" onclick={() => imageAlign = opt.v} class="rounded-md px-3 py-1.5 text-xs transition-colors cursor-pointer {imageAlign === opt.v ? 'bg-violet-500/15 text-violet-400 ring-1 ring-violet-500/30' : 'text-[var(--karbon-text-3)] hover:bg-[var(--karbon-bg-2)]'}">{opt.l}</button>
749
+ {/each}
750
+ </div>
751
+ </div>
752
+ <div class="space-y-1"><span class={modalLabel}>Classes CSS</span><input type="text" bind:value={imageClass} placeholder="ex: rounded shadow-lg" class={modalInput} /></div>
753
+ <div class="space-y-1"><span class={modalLabel}>Style inline</span><input type="text" bind:value={imageStyle} placeholder="ex: border-radius: 8px" class="{modalInput} font-mono text-xs" /></div>
754
+ </div>
755
+ <div class="mt-5 flex items-center justify-between">
756
+ <button type="button" onclick={deleteTargetImage} class="text-xs text-red-400 hover:text-red-300 cursor-pointer">Supprimer</button>
757
+ <div class="flex gap-2">
758
+ <button type="button" onclick={() => showImagePropsModal = false} class="{modalBtnCancel} cursor-pointer">Annuler</button>
759
+ <button type="button" onclick={applyImageProps} class="{modalBtnPrimary} cursor-pointer">Appliquer</button>
760
+ </div>
761
+ </div>
762
+ </div>
763
+ </div>
764
+ {/if}
765
+
766
+ <!-- ═══ ELEMENT PROPS MODAL ═══ -->
767
+ {#if showElementProps}
768
+ <div class={modalOverlay} role="presentation" onclick={() => showElementProps = false} onkeydown={(e) => { if (e.key === "Escape") showElementProps = false }}>
769
+ <div class="{modalBox} max-w-md" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
770
+ <div class="flex items-center justify-between mb-4">
771
+ <h3 class="text-sm font-semibold text-[var(--karbon-text)]">Propriétés : <code class="text-violet-400">&lt;{elPropsTag}&gt;</code></h3>
772
+ <button type="button" onclick={() => showElementProps = false} class="text-[var(--karbon-text-4)] hover:text-[var(--karbon-text)] cursor-pointer" aria-label="Fermer"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
773
+ </div>
774
+ <div class="space-y-3">
775
+ <div class="space-y-1"><span class={modalLabel}>ID</span><input type="text" bind:value={elPropsId} placeholder="identifiant" class="{modalInput} font-mono text-xs" /></div>
776
+ <div class="space-y-1"><span class={modalLabel}>Classes CSS</span><input type="text" bind:value={elPropsClass} placeholder="class1 class2" class="{modalInput} font-mono text-xs" /></div>
777
+ <div class="space-y-1"><span class={modalLabel}>Style inline</span><textarea bind:value={elPropsStyle} rows="3" placeholder="color: red; font-size: 16px;" class="{modalInput} font-mono text-xs"></textarea></div>
778
+ </div>
779
+ <div class="mt-5 flex justify-end gap-2">
780
+ <button type="button" onclick={() => showElementProps = false} class="{modalBtnCancel} cursor-pointer">Annuler</button>
781
+ <button type="button" onclick={applyElementProps} class="{modalBtnPrimary} cursor-pointer">Appliquer</button>
782
+ </div>
783
+ </div>
784
+ </div>
785
+ {/if}
786
+
787
+ <!-- ═══ TABLE MODAL ═══ -->
788
+ {#if showTableModal}
789
+ <div class={modalOverlay} role="presentation" onclick={() => showTableModal = false} onkeydown={(e) => { if (e.key === "Escape") showTableModal = false }}>
790
+ <div class="{modalBox} max-w-sm" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
791
+ <div class="flex items-center justify-between mb-4">
792
+ <h3 class="text-sm font-semibold text-[var(--karbon-text)]">Insérer un tableau</h3>
793
+ <button type="button" onclick={() => showTableModal = false} class="text-[var(--karbon-text-4)] hover:text-[var(--karbon-text)] cursor-pointer" aria-label="Fermer"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
794
+ </div>
795
+ <div class="grid grid-cols-2 gap-3">
796
+ <div class="space-y-1"><span class={modalLabel}>Lignes</span><input type="number" bind:value={tableRows} min="1" max="20" class={modalInput} /></div>
797
+ <div class="space-y-1"><span class={modalLabel}>Colonnes</span><input type="number" bind:value={tableCols} min="1" max="10" class={modalInput} /></div>
798
+ </div>
799
+ <div class="mt-5 flex justify-end gap-2">
800
+ <button type="button" onclick={() => showTableModal = false} class="{modalBtnCancel} cursor-pointer">Annuler</button>
801
+ <button type="button" onclick={insertTable} class="{modalBtnPrimary} cursor-pointer">Insérer</button>
802
+ </div>
803
+ </div>
804
+ </div>
805
+ {/if}
806
+
807
+ <!-- ═══ EMBED MODAL ═══ -->
808
+ {#if showEmbedModal}
809
+ <div class={modalOverlay} role="presentation" onclick={() => showEmbedModal = false} onkeydown={(e) => { if (e.key === "Escape") showEmbedModal = false }}>
810
+ <div class="{modalBox} max-w-md" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
811
+ <div class="flex items-center justify-between mb-4">
812
+ <h3 class="text-sm font-semibold text-[var(--karbon-text)]">Embed vidéo</h3>
813
+ <button type="button" onclick={() => showEmbedModal = false} class="text-[var(--karbon-text-4)] hover:text-[var(--karbon-text)] cursor-pointer" aria-label="Fermer"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
814
+ </div>
815
+ <p class="text-xs text-[var(--karbon-text-3)] mb-3">Collez une URL YouTube, Vimeo ou un lien embed.</p>
816
+ <input type="url" bind:value={embedUrl} placeholder="https://www.youtube.com/watch?v=..." class={modalInput} onkeydown={(e) => { if (e.key === 'Enter') insertEmbed() }} />
817
+ <div class="mt-5 flex justify-end gap-2">
818
+ <button type="button" onclick={() => showEmbedModal = false} class="{modalBtnCancel} cursor-pointer">Annuler</button>
819
+ <button type="button" onclick={insertEmbed} disabled={!embedUrl} class="{modalBtnPrimary} cursor-pointer">Insérer</button>
820
+ </div>
821
+ </div>
822
+ </div>
823
+ {/if}
824
+
825
+ <!-- ═══ MEDIA EXPLORER (simple) ═══ -->
826
+ {#if showMediaExplorer && media}
827
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" role="presentation" onclick={() => showMediaExplorer = false} onkeydown={(e) => { if (e.key === "Escape") showMediaExplorer = false }}>
828
+ <div class="flex h-[70vh] w-[80vw] max-w-4xl flex-col rounded-2xl border border-[var(--karbon-border)] bg-[var(--karbon-bg-card)] shadow-2xl overflow-hidden" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
829
+ <div class="flex items-center gap-3 border-b border-[var(--karbon-border)] px-5 py-3">
830
+ <h2 class="text-sm font-semibold text-[var(--karbon-text)]">Explorateur de médias</h2>
831
+ <div class="flex-1"></div>
832
+ <input type="text" bind:value={mediaSearch} placeholder="Rechercher..." class="{modalInput} w-48 !py-1.5 !text-xs" onkeydown={(e) => { if (e.key === 'Enter') browseMedia() }} />
833
+ <button type="button" onclick={() => showMediaExplorer = false} class="rounded-lg p-1.5 text-[var(--karbon-text-4)] hover:bg-[var(--karbon-bg-2)] hover:text-[var(--karbon-text)] cursor-pointer" aria-label="Fermer">
834
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
835
+ </button>
836
+ </div>
837
+
838
+ <div class="flex-1 overflow-y-auto p-4">
839
+ {#if mediaLoading}
840
+ <div class="flex items-center justify-center py-20">
841
+ <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin text-violet-400"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
842
+ </div>
843
+ {:else if mediaFiles.length === 0}
844
+ <div class="flex flex-col items-center justify-center py-20 text-[var(--karbon-text-4)]">
845
+ <p class="text-sm">Aucun média trouvé</p>
846
+ </div>
847
+ {:else}
848
+ <div class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-3">
849
+ {#each mediaFiles as file}
850
+ <div
851
+ class="group flex flex-col items-center rounded-lg p-2 cursor-pointer transition-all {mediaSelected?.id === file.id ? 'bg-violet-500/15 ring-1 ring-violet-500/40' : 'hover:bg-[var(--karbon-bg-2)]'}"
852
+ role="option" tabindex="0" aria-selected={mediaSelected?.id === file.id} onclick={() => mediaSelected = mediaSelected?.id === file.id ? null : file} onkeydown={(e) => { if (e.key === "Enter") { mediaSelected = mediaSelected?.id === file.id ? null : file } }}
853
+ ondblclick={() => { if (file.url) handleMediaSelect(file.url) }}
854
+ >
855
+ {#if file.type?.startsWith('image/')}
856
+ <div class="h-16 w-16 overflow-hidden rounded-md bg-[var(--karbon-bg-2)]">
857
+ <img src={file.url} alt={file.name} class="h-full w-full object-cover" loading="lazy" />
858
+ </div>
859
+ {:else}
860
+ <div class="flex h-16 w-16 items-center justify-center">
861
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--karbon-text-4)]"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
862
+ </div>
863
+ {/if}
864
+ <p class="mt-1.5 w-full truncate text-center text-[11px] text-[var(--karbon-text-2)]">{file.name}</p>
865
+ </div>
866
+ {/each}
867
+ </div>
868
+ {/if}
869
+ </div>
870
+
871
+ <div class="flex items-center gap-3 border-t border-[var(--karbon-border)] bg-[var(--karbon-bg-2)]/50 px-5 py-3">
872
+ <p class="flex-1 text-xs text-[var(--karbon-text-4)]">{mediaFiles.length} fichier{mediaFiles.length !== 1 ? 's' : ''}</p>
873
+ <button type="button" onclick={() => showMediaExplorer = false} class="{modalBtnCancel} cursor-pointer">Annuler</button>
874
+ <button type="button" onclick={() => { if (mediaSelected?.url) handleMediaSelect(mediaSelected.url) }} disabled={!mediaSelected?.url} class="{modalBtnPrimary} cursor-pointer">Sélectionner</button>
875
+ </div>
876
+ </div>
877
+ </div>
878
+ {/if}
879
+
880
+ {#if showColorPicker}
881
+ <div class="fixed inset-0 z-10" role="presentation" onclick={() => showColorPicker = false} onkeydown={(e) => { if (e.key === "Escape") showColorPicker = false }}></div>
882
+ {/if}
@@ -56,8 +56,15 @@
56
56
 
57
57
  // Calendar state
58
58
  const selectedDate = $derived(value ? new Date(value + 'T00:00:00') : null)
59
- let viewYear = $state(selectedDate?.getFullYear() ?? new Date().getFullYear())
60
- let viewMonth = $state(selectedDate?.getMonth() ?? new Date().getMonth())
59
+ let viewYear = $state(new Date().getFullYear())
60
+ let viewMonth = $state(new Date().getMonth())
61
+
62
+ $effect(() => {
63
+ if (selectedDate) {
64
+ viewYear = selectedDate.getFullYear()
65
+ viewMonth = selectedDate.getMonth()
66
+ }
67
+ })
61
68
 
62
69
  const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
63
70
  const dayNames = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di']
@@ -124,7 +124,7 @@
124
124
  {required}
125
125
  {disabled}
126
126
  {readonly}
127
- {autocomplete}
127
+ autocomplete={autocomplete as any}
128
128
  {oninput}
129
129
  {onchange}
130
130
  {onkeydown}
@@ -38,8 +38,7 @@
38
38
  value={opt.value}
39
39
  checked={value === opt.value}
40
40
  disabled={opt.disabled || disabled}
41
- {onchange}
42
- onchange={() => value = opt.value}
41
+ onchange={(e) => { value = opt.value; onchange?.(e) }}
43
42
  class="mt-0.5 h-4 w-4 shrink-0 border-[var(--karbon-border-input,rgba(255,255,255,0.10))] bg-[var(--karbon-bg-input,rgba(255,255,255,0.06))] text-[var(--karbon-primary)] focus:ring-2 focus:ring-[var(--karbon-primary)]/20 focus:ring-offset-0 transition-colors cursor-pointer disabled:cursor-not-allowed"
44
43
  />
45
44
  <div class="select-none">
@@ -27,28 +27,21 @@
27
27
  } as const
28
28
 
29
29
  const s = $derived(sizes[size])
30
-
31
- function toggle() {
32
- if (!disabled) checked = !checked
33
- }
34
30
  </script>
35
31
 
36
32
  <label class="inline-flex items-center gap-2.5 {disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} {className}">
37
- <input type="checkbox" {name} bind:checked {disabled} {onchange} class="sr-only" />
38
- <!-- svelte-ignore a11y_click_events_have_key_events -->
39
- <!-- svelte-ignore a11y_no_static_element_interactions -->
40
- <div
41
- onclick={toggle}
33
+ <input type="checkbox" {name} bind:checked {disabled} {onchange} class="sr-only peer" />
34
+ <span
42
35
  class="relative inline-flex shrink-0 items-center rounded-full transition-colors duration-200
43
36
  {s.track}
44
- {checked ? 'bg-[var(--karbon-primary)]' : 'bg-[var(--karbon-border,rgba(0,0,0,0.07))]'}"
45
- role="switch"
46
- aria-checked={checked}
37
+ {checked ? 'bg-[var(--karbon-primary)]' : 'bg-[var(--karbon-border,rgba(0,0,0,0.07))]'}
38
+ peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--karbon-primary)]/20"
39
+ aria-hidden="true"
47
40
  >
48
41
  <span
49
42
  class="inline-block rounded-full bg-white shadow-sm transition-transform duration-200 {s.dot} {checked ? s.translate : 'translate-x-0.5'}"
50
43
  ></span>
51
- </div>
44
+ </span>
52
45
  {#if label}
53
46
  <span class="text-sm font-medium text-[var(--karbon-text,#1a1635)] select-none">{label}</span>
54
47
  {/if}
@@ -64,20 +64,15 @@
64
64
  }
65
65
  </script>
66
66
 
67
- <!-- svelte-ignore a11y_click_events_have_key_events -->
68
- <!-- svelte-ignore a11y_no_static_element_interactions -->
69
- <div
70
- class="group overflow-hidden {roundedClasses[rounded]} {aspectClasses[aspect]} {isClickable ? 'cursor-pointer' : ''} {className}"
71
- onclick={isClickable ? handleClick : undefined}
72
- >
73
- <img
74
- src={imgSrc}
75
- {alt}
76
- onerror={handleError}
77
- class="w-full h-full object-cover transition-all duration-300 {hoverClasses[hover]}"
78
- loading="lazy"
79
- />
80
- </div>
67
+ {#if isClickable}
68
+ <button type="button" onclick={handleClick} class="group overflow-hidden {roundedClasses[rounded]} {aspectClasses[aspect]} cursor-pointer bg-transparent border-none p-0 m-0 block {className}">
69
+ <img src={imgSrc} {alt} onerror={handleError} class="w-full h-full object-cover transition-all duration-300 {hoverClasses[hover]}" loading="lazy" />
70
+ </button>
71
+ {:else}
72
+ <div class="group overflow-hidden {roundedClasses[rounded]} {aspectClasses[aspect]} {className}">
73
+ <img src={imgSrc} {alt} onerror={handleError} class="w-full h-full object-cover transition-all duration-300 {hoverClasses[hover]}" loading="lazy" />
74
+ </div>
75
+ {/if}
81
76
 
82
77
  {#if imgbox && imgboxOpen}
83
78
  <ImgBox
@@ -21,7 +21,7 @@
21
21
  class: className = ''
22
22
  }: Props = $props()
23
23
 
24
- let container: HTMLDivElement
24
+ let container = $state<HTMLElement>(undefined!)
25
25
  let zooming = $state(false)
26
26
  let posX = $state(50)
27
27
  let posY = $state(50)
@@ -43,10 +43,7 @@
43
43
  }
44
44
 
45
45
  function handleMouseEnter(e: MouseEvent) {
46
- if (trigger === 'hover') {
47
- zooming = true
48
- updatePosition(e)
49
- }
46
+ if (trigger === 'hover') { zooming = true; updatePosition(e) }
50
47
  }
51
48
 
52
49
  function handleMouseMove(e: MouseEvent) {
@@ -58,39 +55,42 @@
58
55
  }
59
56
 
60
57
  function handleClick(e: MouseEvent) {
61
- if (trigger === 'click') {
62
- zooming = !zooming
63
- if (zooming) updatePosition(e)
64
- }
58
+ zooming = !zooming
59
+ if (zooming) updatePosition(e)
65
60
  }
66
- </script>
67
61
 
68
- <!-- svelte-ignore a11y_click_events_have_key_events -->
69
- <!-- svelte-ignore a11y_no_static_element_interactions -->
70
- <div
71
- bind:this={container}
72
- class="relative overflow-hidden {roundedClasses[rounded]} {trigger === 'click' ? 'cursor-zoom-in' : ''} {zooming && trigger === 'click' ? 'cursor-zoom-out' : ''} {className}"
73
- onmouseenter={handleMouseEnter}
74
- onmousemove={handleMouseMove}
75
- onmouseleave={handleMouseLeave}
76
- onclick={handleClick}
77
- >
78
- <img
79
- {src}
80
- {alt}
81
- class="w-full h-full object-cover block"
82
- loading="lazy"
83
- />
62
+ function handleKeydown(e: KeyboardEvent) {
63
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); zooming = !zooming }
64
+ }
65
+ </script>
84
66
 
85
- {#if zooming}
86
- <div
87
- class="absolute inset-0 pointer-events-none"
88
- style="
89
- background-image: url({zoomImage});
90
- background-size: {zoom * 100}%;
91
- background-position: {posX}% {posY}%;
92
- background-repeat: no-repeat;
93
- "
94
- ></div>
95
- {/if}
96
- </div>
67
+ {#if trigger === 'click'}
68
+ <button
69
+ type="button"
70
+ bind:this={container}
71
+ class="relative overflow-hidden {roundedClasses[rounded]} bg-transparent border-none p-0 m-0 block {zooming ? 'cursor-zoom-out' : 'cursor-zoom-in'} {className}"
72
+ onmousemove={handleMouseMove}
73
+ onmouseleave={handleMouseLeave}
74
+ onclick={handleClick}
75
+ >
76
+ <img {src} {alt} class="w-full h-full object-cover block" loading="lazy" />
77
+ {#if zooming}
78
+ <span class="absolute inset-0 pointer-events-none" style="background-image: url({zoomImage}); background-size: {zoom * 100}%; background-position: {posX}% {posY}%; background-repeat: no-repeat;"></span>
79
+ {/if}
80
+ </button>
81
+ {:else}
82
+ <div
83
+ bind:this={container}
84
+ role="img"
85
+ aria-label={alt || 'Zoomable image'}
86
+ class="relative overflow-hidden {roundedClasses[rounded]} {className}"
87
+ onmouseenter={handleMouseEnter}
88
+ onmousemove={handleMouseMove}
89
+ onmouseleave={handleMouseLeave}
90
+ >
91
+ <img {src} {alt} class="w-full h-full object-cover block" loading="lazy" />
92
+ {#if zooming}
93
+ <div class="absolute inset-0 pointer-events-none" style="background-image: url({zoomImage}); background-size: {zoom * 100}%; background-position: {posX}% {posY}%; background-repeat: no-repeat;"></div>
94
+ {/if}
95
+ </div>
96
+ {/if}
package/src/index.ts CHANGED
@@ -66,6 +66,9 @@ export { default as Divider } from './divider/Divider.svelte'
66
66
  // kbd
67
67
  export { default as Kbd } from './kbd/Kbd.svelte'
68
68
 
69
+ // editor
70
+ export { default as RichTextEditor } from './editor/RichTextEditor.svelte'
71
+
69
72
  // data
70
73
  export { default as DataTable } from './data/DataTable.svelte'
71
74
  export { default as Pagination } from './data/Pagination.svelte'
@@ -26,8 +26,8 @@
26
26
  }
27
27
  </script>
28
28
 
29
- <!-- svelte-ignore a11y_no_static_element_interactions -->
30
29
  <div
30
+ role="group"
31
31
  class="relative inline-block {className}"
32
32
  onmouseenter={() => visible = true}
33
33
  onmouseleave={() => visible = false}