@kolkrabbi/kol-component 0.207.0 → 0.208.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/hooks/svgExport.js +146 -0
- package/src/hooks/useHistory.js +121 -0
- package/src/index.js +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.208.0",
|
|
4
4
|
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
37
|
+
"@kolkrabbi/kol-icons": "^0.27.0"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"src",
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* svgExport — turn a finished SVG string into pixels, and embed the fonts it
|
|
3
|
+
* needs so the export reads like the screen.
|
|
4
|
+
*
|
|
5
|
+
* Packaged 2026-09-04 (`export-and-history-want-packaging`, kol-fxr for
|
|
6
|
+
* kol-client-olina, who had copied ~180 lines of it). **The BUILDER is
|
|
7
|
+
* deliberately not here.** Assembling an SVG welds to an app's own layer
|
|
8
|
+
* schema — slides in one editor, compose layers in another — so it was never
|
|
9
|
+
* the reusable half. This takes a finished string and gives back a Blob.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rasterize an SVG string to a PNG Blob at `scale`× the SVG's own dimensions.
|
|
14
|
+
* Triggers no download.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} svgString - A complete, self-contained SVG document
|
|
17
|
+
* @param {number} [scale=1] - The @Nx multiplier
|
|
18
|
+
* @param {number} [fallbackSize=1080] - Used when the SVG declares no intrinsic size
|
|
19
|
+
* @returns {Promise<Blob>} the PNG
|
|
20
|
+
*/
|
|
21
|
+
export function svgToPngBlob(svgString, scale = 1, fallbackSize = 1080) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
|
24
|
+
const url = URL.createObjectURL(blob)
|
|
25
|
+
const img = new Image()
|
|
26
|
+
img.onload = () => {
|
|
27
|
+
const canvas = document.createElement('canvas')
|
|
28
|
+
canvas.width = (img.width || fallbackSize) * scale
|
|
29
|
+
canvas.height = (img.height || fallbackSize) * scale
|
|
30
|
+
const ctx = canvas.getContext('2d')
|
|
31
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
|
32
|
+
URL.revokeObjectURL(url)
|
|
33
|
+
canvas.toBlob((pngBlob) => {
|
|
34
|
+
if (pngBlob) resolve(pngBlob)
|
|
35
|
+
else reject(new Error('PNG encode failed'))
|
|
36
|
+
}, 'image/png')
|
|
37
|
+
}
|
|
38
|
+
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG rasterize failed')) }
|
|
39
|
+
img.src = url
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const b64 = (buf) => {
|
|
44
|
+
const bytes = new Uint8Array(buf)
|
|
45
|
+
let bin = ''
|
|
46
|
+
const CH = 0x8000
|
|
47
|
+
for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH))
|
|
48
|
+
return btoa(bin)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const FORMAT = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' }
|
|
52
|
+
const formatFor = (url) => FORMAT[(url.split('?')[0].split('.').pop() || '').toLowerCase()] ?? 'truetype'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Inline every font a stylesheet references, by REWRITING its `@font-face`
|
|
56
|
+
* blocks and swapping only the `src`.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ **NEVER SYNTHESIZE A FACE.** This is the bug the seam exists to stop
|
|
59
|
+
* (kol-client-olina, 2026-09-04): they harvested bare `url(...)` matches out
|
|
60
|
+
* of Google's CSS and emitted their own `@font-face` without `unicode-range`.
|
|
61
|
+
* Google splits ONE family into latin / latin-ext / cyrillic / greek subsets
|
|
62
|
+
* that are distinguished by that range and nothing else — so the first subset
|
|
63
|
+
* fetched got embedded, matched no glyph, and every family exported in a system
|
|
64
|
+
* fallback while the screen looked perfect. Rewriting the whole block keeps
|
|
65
|
+
* `unicode-range`, the weight and stretch ranges, and anything else the
|
|
66
|
+
* foundry declared.
|
|
67
|
+
*
|
|
68
|
+
* A face that fails to fetch is REPORTED, not swallowed: a silent `catch {}`
|
|
69
|
+
* is how the same export goes out in fallback with nothing to look at.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} cssText - The stylesheet whose `@font-face` blocks to inline
|
|
72
|
+
* @param {Function} [fetchFont] - `(url) => Promise<ArrayBuffer>`; defaults to `fetch`
|
|
73
|
+
* @returns {Promise<{css: string, failed: Array<{url: string, error: Error}>}>} the rewritten CSS and every face that could not be embedded
|
|
74
|
+
*/
|
|
75
|
+
export async function inlineFontFaces(cssText, fetchFont) {
|
|
76
|
+
const load = fetchFont ?? (async (url) => {
|
|
77
|
+
const r = await fetch(url)
|
|
78
|
+
if (!r.ok) throw new Error(`font fetch failed: ${r.status} ${url}`)
|
|
79
|
+
return r.arrayBuffer()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const failed = []
|
|
83
|
+
const blocks = [...cssText.matchAll(/@font-face\s*{[^}]*}/g)].map((m) => m[0])
|
|
84
|
+
const urls = new Map()
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
for (const m of block.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)) {
|
|
87
|
+
if (!m[1].startsWith('data:')) urls.set(m[1], null)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await Promise.all([...urls.keys()].map(async (url) => {
|
|
92
|
+
try {
|
|
93
|
+
urls.set(url, `data:font/${formatFor(url)};base64,${b64(await load(url))}`)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
failed.push({ url, error })
|
|
96
|
+
}
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
/* Only the `src` is touched — every other descriptor in the block, and
|
|
100
|
+
* `unicode-range` above all, survives exactly as authored. */
|
|
101
|
+
const css = cssText.replace(/url\(\s*['"]?([^'")]+)['"]?\s*\)(\s*format\(\s*['"]?[^'")]+['"]?\s*\))?/g,
|
|
102
|
+
(whole, url) => {
|
|
103
|
+
const data = urls.get(url)
|
|
104
|
+
return data ? `url(${data}) format('${formatFor(url)}')` : whole
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return { css, failed }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build one `@font-face` block for a SELF-HOSTED, full-range file — the only
|
|
112
|
+
* case where synthesizing is safe, because there are no subsets to confuse.
|
|
113
|
+
* Anything served as subsets (Google's families, any foundry CSS) must go
|
|
114
|
+
* through `inlineFontFaces` instead.
|
|
115
|
+
*
|
|
116
|
+
* @param {{family: string, url: string, weight?: string, stretch?: string}} face
|
|
117
|
+
* @param {Function} [fetchFont] - `(url) => Promise<ArrayBuffer>`
|
|
118
|
+
* @returns {Promise<string>} the `@font-face` block, fonts inlined
|
|
119
|
+
*/
|
|
120
|
+
export async function embedFontFace({ family, url, weight = '1 1000', stretch = '1% 1000%' }, fetchFont) {
|
|
121
|
+
const load = fetchFont ?? (async (u) => {
|
|
122
|
+
const r = await fetch(u)
|
|
123
|
+
if (!r.ok) throw new Error(`font fetch failed: ${r.status} ${u}`)
|
|
124
|
+
return r.arrayBuffer()
|
|
125
|
+
})
|
|
126
|
+
const data = `data:font/${formatFor(url)};base64,${b64(await load(url))}`
|
|
127
|
+
return `@font-face{font-family:'${family}';src:url(${data}) format('${formatFor(url)}');font-weight:${weight};font-stretch:${stretch};}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Download a Blob under a filename. The one place a DOM anchor is minted for
|
|
132
|
+
* a save, so a consumer's export path does not hand-roll it.
|
|
133
|
+
*
|
|
134
|
+
* @param {Blob} blob
|
|
135
|
+
* @param {string} filename
|
|
136
|
+
*/
|
|
137
|
+
export function downloadBlob(blob, filename) {
|
|
138
|
+
const url = URL.createObjectURL(blob)
|
|
139
|
+
const a = document.createElement('a')
|
|
140
|
+
a.href = url
|
|
141
|
+
a.download = filename
|
|
142
|
+
document.body.appendChild(a)
|
|
143
|
+
a.click()
|
|
144
|
+
a.remove()
|
|
145
|
+
URL.revokeObjectURL(url)
|
|
146
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useHistory — undo/redo over a value the CONSUMER owns, with transactions.
|
|
5
|
+
*
|
|
6
|
+
* Packaged 2026-09-04 (`export-and-history-want-packaging`, kol-fxr for
|
|
7
|
+
* kol-client-olina, who had copied it on our advice and came back asking for
|
|
8
|
+
* it by name). **Generic over the value, deliberately**: one editor's snapshot
|
|
9
|
+
* is `{ slides, active, selectedIds }`, another's is compose state. The
|
|
10
|
+
* consumer's words, and they are right — *if it knows about layers it stops
|
|
11
|
+
* being reusable*. Nothing here inspects what it stores.
|
|
12
|
+
*
|
|
13
|
+
* TWO THINGS MAKE IT WORTH PACKAGING, and both are behaviour rather than code:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Selection rides INSIDE the snapshot.** Undo restores what was selected,
|
|
16
|
+
* not only what was drawn — so put the selection in the value. An undo that
|
|
17
|
+
* redraws the old shape but leaves a stale selection is the version everyone
|
|
18
|
+
* writes first and nobody wants.
|
|
19
|
+
* 2. **A drag is ONE entry.** `begin()` before a gesture and `end()` after it:
|
|
20
|
+
* every `set` between them updates the live value without pushing, and `end`
|
|
21
|
+
* pushes once. Without it a pointer-move drag floods the stack.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ **NEVER PUSH INSIDE A `setState` UPDATER.** React StrictMode invokes
|
|
24
|
+
* updaters twice, so every entry doubles — silently, in dev only, and it looks
|
|
25
|
+
* like undo "skipping". The next value is computed against a ref here, outside
|
|
26
|
+
* the updater, which is what makes that impossible rather than merely avoided.
|
|
27
|
+
*
|
|
28
|
+
* const { value, set, begin, end, undo, redo, reset, canUndo, canRedo } = useHistory(initial)
|
|
29
|
+
* onPointerDown={begin} onPointerMove={(e) => set(next(e))} onPointerUp={end}
|
|
30
|
+
*
|
|
31
|
+
* @param {*} initialValue - The first snapshot; any shape
|
|
32
|
+
* @param {number} [limit=100] - Entries kept before the oldest is dropped
|
|
33
|
+
* @returns {{value: *, set: Function, begin: Function, end: Function, undo: Function, redo: Function, reset: Function, canUndo: boolean, canRedo: boolean}}
|
|
34
|
+
*/
|
|
35
|
+
export default function useHistory(initialValue, limit = 100) {
|
|
36
|
+
const [value, setValue] = useState(initialValue)
|
|
37
|
+
/* The ref is the truth the history reads. State drives the render; this
|
|
38
|
+
* drives the stack, so a push never depends on an updater running once. */
|
|
39
|
+
const valueRef = useRef(initialValue)
|
|
40
|
+
const past = useRef([])
|
|
41
|
+
const future = useRef([])
|
|
42
|
+
const txn = useRef(null) /* the value as it was when begin() was called */
|
|
43
|
+
const [, bump] = useState(0)
|
|
44
|
+
const rerender = () => bump((n) => n + 1)
|
|
45
|
+
|
|
46
|
+
const commit = useCallback((next) => {
|
|
47
|
+
valueRef.current = next
|
|
48
|
+
setValue(next)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
/* `set` takes a value or a producer. The producer is called HERE, against
|
|
52
|
+
* the ref, never inside setState — see the StrictMode note above. */
|
|
53
|
+
const set = useCallback((nextOrFn) => {
|
|
54
|
+
const prev = valueRef.current
|
|
55
|
+
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn
|
|
56
|
+
if (Object.is(next, prev)) return
|
|
57
|
+
if (txn.current === null) {
|
|
58
|
+
past.current = [...past.current, prev].slice(-limit)
|
|
59
|
+
future.current = []
|
|
60
|
+
}
|
|
61
|
+
commit(next)
|
|
62
|
+
rerender()
|
|
63
|
+
}, [commit, limit])
|
|
64
|
+
|
|
65
|
+
/* Open a transaction: `set` keeps updating the live value, and the entry
|
|
66
|
+
* that lands on `end` is the value as it was when this was called. */
|
|
67
|
+
const begin = useCallback(() => {
|
|
68
|
+
if (txn.current === null) txn.current = { from: valueRef.current }
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
const end = useCallback(() => {
|
|
72
|
+
const open = txn.current
|
|
73
|
+
txn.current = null
|
|
74
|
+
if (!open) return
|
|
75
|
+
if (Object.is(open.from, valueRef.current)) return /* a gesture that moved nothing is not an entry */
|
|
76
|
+
past.current = [...past.current, open.from].slice(-limit)
|
|
77
|
+
future.current = []
|
|
78
|
+
rerender()
|
|
79
|
+
}, [limit])
|
|
80
|
+
|
|
81
|
+
const undo = useCallback(() => {
|
|
82
|
+
if (!past.current.length) return
|
|
83
|
+
const prev = past.current[past.current.length - 1]
|
|
84
|
+
past.current = past.current.slice(0, -1)
|
|
85
|
+
future.current = [valueRef.current, ...future.current]
|
|
86
|
+
commit(prev)
|
|
87
|
+
rerender()
|
|
88
|
+
}, [commit])
|
|
89
|
+
|
|
90
|
+
const redo = useCallback(() => {
|
|
91
|
+
if (!future.current.length) return
|
|
92
|
+
const next = future.current[0]
|
|
93
|
+
future.current = future.current.slice(1)
|
|
94
|
+
past.current = [...past.current, valueRef.current].slice(-limit)
|
|
95
|
+
commit(next)
|
|
96
|
+
rerender()
|
|
97
|
+
}, [commit, limit])
|
|
98
|
+
|
|
99
|
+
/* Drop the whole stack and start again (a fresh document, a loaded file).
|
|
100
|
+
* Not an undoable step — there is nothing behind it. Omit `next` to return
|
|
101
|
+
* to the value the hook was initialised with. */
|
|
102
|
+
const reset = useCallback((next) => {
|
|
103
|
+
past.current = []
|
|
104
|
+
future.current = []
|
|
105
|
+
txn.current = null
|
|
106
|
+
commit(next === undefined ? initialValue : next)
|
|
107
|
+
rerender()
|
|
108
|
+
}, [commit, initialValue])
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
value,
|
|
112
|
+
set,
|
|
113
|
+
begin,
|
|
114
|
+
end,
|
|
115
|
+
undo,
|
|
116
|
+
redo,
|
|
117
|
+
reset,
|
|
118
|
+
canUndo: past.current.length > 0,
|
|
119
|
+
canRedo: future.current.length > 0,
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/index.js
CHANGED
|
@@ -222,6 +222,11 @@ export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
|
222
222
|
* STRUCTURAL responsive fork, where the markup itself changes and a stylesheet
|
|
223
223
|
* cannot express it (ColumnBrowserStackMode, 2026-09-03). Sizes stay Tailwind's. */
|
|
224
224
|
export { default as useMediaQuery } from './hooks/useMediaQuery.js'
|
|
225
|
+
/* The two seams kol-client-olina asked for by name after copying both
|
|
226
|
+
* (export-and-history-want-packaging, 2026-09-04). The SVG BUILDER is
|
|
227
|
+
* deliberately absent — it welds to an app's own layer schema. */
|
|
228
|
+
export { svgToPngBlob, inlineFontFaces, embedFontFace, downloadBlob } from './hooks/svgExport.js'
|
|
229
|
+
export { default as useHistory } from './hooks/useHistory.js'
|
|
225
230
|
/* The rail gesture's other half. It lived in kol-framework until 2026-09-03 and
|
|
226
231
|
* moved here for the same reason `useGrabEdge` did: kol-component's own
|
|
227
232
|
* `EditorShell` needs resizable rails and cannot import framework. framework
|