@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 +2 -2
- package/src/carousel/Carousel.svelte +2 -1
- package/src/dropdown/Dropdown.svelte +3 -5
- package/src/editor/RichTextEditor.svelte +882 -0
- package/src/form/DatePicker.svelte +9 -2
- package/src/form/FormInput.svelte +1 -1
- package/src/form/Radio.svelte +1 -2
- package/src/form/Toggle.svelte +6 -13
- package/src/image/Image.svelte +9 -14
- package/src/image/ImgZoom.svelte +38 -38
- package/src/index.ts +3 -0
- package/src/tooltip/Tooltip.svelte +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karbonjs/ui-svelte",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
</
|
|
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
|
+
[/(<!--[\s\S]*?-->)/g, '<span class="text-gray-600 italic">$1</span>'],
|
|
111
|
+
[/(<\/?)([\w-]+)/g, '<span class="text-gray-500">$1</span><span class="text-green-400">$2</span>'],
|
|
112
|
+
[/([\w-]+)(=)("[^&]*"|'[^&]*')/g, '<span class="text-blue-400">$1</span><span class="text-gray-500">$2</span><span class="text-sky-300">$3</span>'],
|
|
113
|
+
[/(>)/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, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>')
|
|
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> </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 = ' ' }
|
|
329
|
+
else if (action === 'addRowBelow') { const nr = targetTable.insertRow(rowIndex + 1); for (let i = 0; i < row.cells.length; i++) nr.insertCell().innerHTML = ' ' }
|
|
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' : ' ' } }
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
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"><{elPropsTag}></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(
|
|
60
|
-
let viewMonth = $state(
|
|
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']
|
package/src/form/Radio.svelte
CHANGED
|
@@ -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">
|
package/src/form/Toggle.svelte
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
aria-
|
|
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
|
-
</
|
|
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}
|
package/src/image/Image.svelte
CHANGED
|
@@ -64,20 +64,15 @@
|
|
|
64
64
|
}
|
|
65
65
|
</script>
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
package/src/image/ImgZoom.svelte
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
class: className = ''
|
|
22
22
|
}: Props = $props()
|
|
23
23
|
|
|
24
|
-
let container
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
if (zooming) updatePosition(e)
|
|
64
|
-
}
|
|
58
|
+
zooming = !zooming
|
|
59
|
+
if (zooming) updatePosition(e)
|
|
65
60
|
}
|
|
66
|
-
</script>
|
|
67
61
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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'
|