@kolkrabbi/kol-component 0.223.0 → 0.225.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 +1 -1
- package/src/atoms/ColorSwatch.jsx +6 -2
- package/src/atoms/SelectionOverlay.jsx +3 -1
- package/src/index.js +4 -2
- package/src/molecules/ColorInputRow.jsx +3 -2
- package/src/molecules/Dropdown.jsx +10 -5
- package/src/molecules/SectionText.jsx +5 -1
- package/src/molecules/ShapeDropdown.jsx +3 -2
- package/src/molecules/SplitToolButton.jsx +3 -2
- package/src/molecules/SwatchControls.jsx +3 -1
- package/src/organisms/Canvas.jsx +5 -2
- package/src/organisms/DocumentEditor.jsx +282 -0
- package/src/organisms/KeyframeEditor.jsx +2 -1
- package/src/organisms/LayerStack.jsx +11 -6
- package/src/organisms/MediaLibrary.jsx +1 -1
- package/src/organisms/MediaLibraryExplorer.jsx +67 -3
- package/src/organisms/MediaLibraryPages.jsx +358 -197
- package/src/organisms/SettingsPanel.jsx +5 -1
- package/src/organisms/SettingsSections.jsx +2 -1
- package/src/organisms/SpectrumGrid.jsx +3 -2
- package/src/organisms/TimelineDock.jsx +6 -3
- package/src/organisms/ToolPalette.jsx +6 -3
- package/src/utilities/Popover.jsx +61 -11
- package/src/utilities/QuadrantSync.jsx +3 -1
- package/src/utilities/frontmatter.js +47 -0
- package/src/utilities/localDrafts.js +50 -0
|
@@ -171,7 +171,11 @@ export function LabeledControlSection({ label, divided = false, rowGap = 2, chil
|
|
|
171
171
|
* rebuilt this same Dropdown in a local `ChoiceRow`, which is the exact
|
|
172
172
|
* duplication this component exists to prevent. A component that wraps another
|
|
173
173
|
* has to forward the wrapped one's seams or it becomes a wall. */
|
|
174
|
-
|
|
174
|
+
/* `variant` UNSET by default (the Hub review, 2026-09-26): a forced `primary` blocked the
|
|
175
|
+
* tone of a `kol-tone-*` wrapper, so a sunken settings page drew a light well in every row
|
|
176
|
+
* unless the row said `tone="sunken"` itself. Unset, Dropdown takes the wrapper's tone and
|
|
177
|
+
* falls back to primary — the same render wherever there is no wrapper. */
|
|
178
|
+
export function SettingsChoice({ options = [], value, onChange, ariaLabel, tone = 'default', size = 'sm', variant, className = '' }) {
|
|
175
179
|
const opts = options.map((o) => (o != null && typeof o === 'object' ? o : { value: o, label: String(o) }))
|
|
176
180
|
return <Dropdown size={size} variant={variant} tone={tone} value={value} onChange={onChange} options={opts} className={`w-full ${className}`.trim()} aria-label={ariaLabel} />
|
|
177
181
|
}
|
|
@@ -35,7 +35,7 @@ import { LabeledControlSection, SettingsRow } from './SettingsPanel.jsx'
|
|
|
35
35
|
* first included: the rule is `.kol-section--divided + .kol-section--divided`,
|
|
36
36
|
* so the first draws nothing and skipping it just costs the next its line.
|
|
37
37
|
*
|
|
38
|
-
* @param {Array<{label?: string, rows: Array<{label: ReactNode, render?: Function, value?: ReactNode, align?: string, labelWidth?: number|'auto', id?: string}>, rowGap?: number}>} sections - The settings body, in order
|
|
38
|
+
* @param {Array<{label?: string, rows: Array<{label: ReactNode, render?: Function, value?: ReactNode, hint?: string, align?: string, labelWidth?: number|'auto', id?: string}>, rowGap?: number}>} sections - The settings body, in order (`hint` → SettingsRow's hint)
|
|
39
39
|
* @param {boolean} [divided=false] - Hairline above each section
|
|
40
40
|
* @param {number|'auto'} [labelWidth] - Default label column for every row — `'auto'` makes the label yield and the control hug, which is what a narrow column or a nowrap value needs
|
|
41
41
|
* @param {string} [align] - Default row alignment passed to `SettingsRow`
|
|
@@ -50,6 +50,7 @@ export default function SettingsSections({ sections = [], divided = false, label
|
|
|
50
50
|
<SettingsRow
|
|
51
51
|
key={row.id ?? row.label ?? j}
|
|
52
52
|
label={row.label}
|
|
53
|
+
hint={row.hint}
|
|
53
54
|
align={row.align ?? align}
|
|
54
55
|
labelWidth={row.labelWidth ?? labelWidth}
|
|
55
56
|
>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import ColorSwatch from '../atoms/ColorSwatch.jsx'
|
|
3
3
|
import { resolveCssVar, resolveCssColor, isLight } from '../hooks/cssVar.js'
|
|
4
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* SpectrumGrid — matrix view of the whole ramp system: rows = ramps, columns =
|
|
@@ -63,10 +64,9 @@ export default function SpectrumGrid({ ramps, stops = DEFAULT_STOPS, brandAnchor
|
|
|
63
64
|
const textColor = isLight(color) ? '#000' : '#fff'
|
|
64
65
|
const brand = isBrandAnchor(ramp, stop)
|
|
65
66
|
return (
|
|
67
|
+
<Tooltip key={stop} label={`${ramp}-${stop} ${hex}${brand ? ' · brand anchor' : ''}`} asChild>
|
|
66
68
|
<div
|
|
67
|
-
key={stop}
|
|
68
69
|
className="relative aspect-[5/3] rounded overflow-hidden transition-transform hover:scale-[1.04]"
|
|
69
|
-
title={`${ramp}-${stop} ${hex}${brand ? ' · brand anchor' : ''}`}
|
|
70
70
|
>
|
|
71
71
|
<ColorSwatch hex={`var(--${ramp}-${stop})`} size="stretch" radius="none" frame={false} className="absolute inset-0" />
|
|
72
72
|
<div className="pointer-events-none absolute inset-0 px-2 py-1.5" style={{ color: textColor }}>
|
|
@@ -81,6 +81,7 @@ export default function SpectrumGrid({ ramps, stops = DEFAULT_STOPS, brandAnchor
|
|
|
81
81
|
)}
|
|
82
82
|
</div>
|
|
83
83
|
</div>
|
|
84
|
+
</Tooltip>
|
|
84
85
|
)
|
|
85
86
|
})}
|
|
86
87
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useRef, useState } from 'react'
|
|
2
2
|
import Input from '../atoms/Input.jsx'
|
|
3
3
|
import Dropdown from '../molecules/Dropdown.jsx'
|
|
4
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
4
5
|
|
|
5
6
|
/* The six easings the key editor offers. The CURVES stay the consumer's
|
|
6
7
|
* (its interpolator resolves the name); this is the menu, not the math. */
|
|
@@ -123,9 +124,11 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
|
|
|
123
124
|
|
|
124
125
|
return (
|
|
125
126
|
<div className="flex items-center gap-3">
|
|
126
|
-
<
|
|
127
|
+
<Tooltip label={track.label} asChild>
|
|
128
|
+
<span className="kol-helper-10 text-meta truncate shrink-0 text-right" style={{ width: 120 }}>
|
|
127
129
|
{track.label}
|
|
128
130
|
</span>
|
|
131
|
+
</Tooltip>
|
|
129
132
|
<div
|
|
130
133
|
ref={laneRef}
|
|
131
134
|
className="relative flex-1 h-5 rounded cursor-copy"
|
|
@@ -136,13 +139,12 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
|
|
|
136
139
|
{track.keys.map((k, i) => {
|
|
137
140
|
const kt = drag.current?.index === i ? drag.current.t : k.t
|
|
138
141
|
return (
|
|
142
|
+
<Tooltip key={i} label={`t=${kt.toFixed(2)} v=${typeof k.v === 'number' ? Math.round(k.v * 100) / 100 : k.v} (alt-click deletes)`} asChild>
|
|
139
143
|
<span
|
|
140
|
-
key={i}
|
|
141
144
|
data-diamond=""
|
|
142
145
|
onPointerDown={onDiamondPointerDown(i)}
|
|
143
146
|
onPointerMove={onDiamondPointerMove(i)}
|
|
144
147
|
onPointerUp={onDiamondPointerUp(i)}
|
|
145
|
-
title={`t=${kt.toFixed(2)} v=${typeof k.v === 'number' ? Math.round(k.v * 100) / 100 : k.v} (alt-click deletes)`}
|
|
146
148
|
className="absolute top-1/2 cursor-grab"
|
|
147
149
|
style={{
|
|
148
150
|
left: `${kt * 100}%`,
|
|
@@ -152,6 +154,7 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
|
|
|
152
154
|
borderRadius: 1.5,
|
|
153
155
|
}}
|
|
154
156
|
/>
|
|
157
|
+
</Tooltip>
|
|
155
158
|
)
|
|
156
159
|
})}
|
|
157
160
|
</div>
|
|
@@ -2,6 +2,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
2
2
|
import Button from '../atoms/Button.jsx'
|
|
3
3
|
import Divider from '../atoms/Divider.jsx'
|
|
4
4
|
import SplitToolButton from '../molecules/SplitToolButton.jsx'
|
|
5
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* ToolPalette — the editor's tool bar as one row (kol-fxr `shell/panels/ToolPalette.jsx`,
|
|
@@ -45,7 +46,6 @@ const ToolPalette = ({
|
|
|
45
46
|
<div className={`flex items-center gap-1 min-w-0 overflow-x-auto ${className}`.trim()}>
|
|
46
47
|
{items.map((it, i) => {
|
|
47
48
|
if (it.kind === 'divider') return <Divider key={`d${i}`} variant="vertical" height={20} className="mx-1.5 shrink-0" />
|
|
48
|
-
const tip = it.shortcut ? `${it.label} (${it.shortcut})` : it.label
|
|
49
49
|
if (it.kind === 'split') {
|
|
50
50
|
const variants = (it.variants ?? []).map((v) => (v.action && !it.action ? { ...v, onSelect: () => onAction?.(v.id) } : v))
|
|
51
51
|
const run = (id) => (it.action ? onAction : onSelect)?.(id)
|
|
@@ -58,10 +58,13 @@ const ToolPalette = ({
|
|
|
58
58
|
}
|
|
59
59
|
const isTool = it.kind === 'tool'
|
|
60
60
|
return (
|
|
61
|
-
|
|
61
|
+
/* the DS tooltip carries the key as its own chip (the native-title sweep, 2026-09-26) */
|
|
62
|
+
<Tooltip key={it.id} label={it.label} shortcut={it.shortcut}>
|
|
63
|
+
<Button variant="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
|
|
62
64
|
pressed={isTool ? activeId === it.id : undefined} disabled={it.disabled}
|
|
63
|
-
aria-label={it.label}
|
|
65
|
+
aria-label={it.label}
|
|
64
66
|
onClick={(e) => { (isTool ? onSelect : onAction)?.(it.id); blur(e) }} />
|
|
67
|
+
</Tooltip>
|
|
65
68
|
)
|
|
66
69
|
})}
|
|
67
70
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createContext, useContext, useState } from 'react'
|
|
2
|
-
import { toneClass } from './tone.js'
|
|
1
|
+
import { cloneElement, createContext, isValidElement, useContext, useLayoutEffect, useState, version as reactVersion } from 'react'
|
|
2
|
+
import { toneClass, TONE_VARS } from './tone.js'
|
|
3
3
|
import {
|
|
4
4
|
useFloating,
|
|
5
5
|
autoUpdate,
|
|
@@ -136,13 +136,29 @@ export function usePopover({
|
|
|
136
136
|
* — the innermost wins, as the native `title` it replaced behaved. */
|
|
137
137
|
const TooltipNest = createContext(null)
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
/* `asChild` (the native-title sweep, 2026-09-26): the tooltip attaches to its one child element
|
|
140
|
+
* instead of wrapping it in a span — for a trigger whose box IS the layout (a grid cell, a rail row,
|
|
141
|
+
* an absolutely placed handle), where an inline-flex wrapper would move it. The child must be an
|
|
142
|
+
* element that takes a ref: an intrinsic tag always does; a component only if it forwards one
|
|
143
|
+
* (React 18 needs forwardRef), so Button / IconFrame keep the wrapper. Its own ref and handlers are
|
|
144
|
+
* kept — the reference props merge over the child's. */
|
|
145
|
+
const childRefOf = (el) => ('ref' in (el.props ?? {}) ? el.props.ref : reactVersion.startsWith('18') ? el.ref : undefined)
|
|
146
|
+
|
|
147
|
+
export function Tooltip(props) {
|
|
148
|
+
/* no label → no tooltip: several call sites pass an optional hint, and an empty panel is worse
|
|
149
|
+
* than none. A separate component so the hooks below never run conditionally. */
|
|
150
|
+
if (props.label == null || props.label === '') return props.children
|
|
151
|
+
return <TooltipOn {...props} />
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function TooltipOn({
|
|
140
155
|
label,
|
|
141
156
|
shortcut,
|
|
142
157
|
placement = 'bottom',
|
|
143
158
|
offset = 6,
|
|
144
159
|
children,
|
|
145
160
|
triggerClassName = 'inline-flex',
|
|
161
|
+
asChild = false,
|
|
146
162
|
}) {
|
|
147
163
|
const [open, setOpen] = useState(false)
|
|
148
164
|
const [covered, setCovered] = useState(false)
|
|
@@ -158,22 +174,56 @@ export function Tooltip({
|
|
|
158
174
|
focus: true,
|
|
159
175
|
})
|
|
160
176
|
|
|
177
|
+
/* THE TONE OF WHERE IT IS (user, 2026-09-26: "tooltip should probably use same tone as dropdowns
|
|
178
|
+
* and buttons"). The panel is portalled to body, outside the `kol-tone-*` wrapper that tones the
|
|
179
|
+
* controls around the trigger — Dropdown's problem, solved the same way: on open, copy the
|
|
180
|
+
* trigger's RESOLVED `--kol-tone-*` onto the panel. No tone in effect = nothing copied, and the
|
|
181
|
+
* tooltip keeps its own surface. */
|
|
182
|
+
const [ambient, setAmbient] = useState(null)
|
|
183
|
+
const shown = open && !covered
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
const ref = popover.refs.reference.current
|
|
186
|
+
/* the SURROUNDINGS' tone, not the trigger's own: an `asChild` trigger can be a variant button
|
|
187
|
+
* (`kol-btn-ghost` carries its own `--kol-tone-*`), so read its parent; the wrapper span has no
|
|
188
|
+
* variant and inherits the ambient values as it is */
|
|
189
|
+
const el = asChild ? ref?.parentElement : ref
|
|
190
|
+
if (!shown || !(el instanceof Element)) return
|
|
191
|
+
const cs = getComputedStyle(el)
|
|
192
|
+
const vars = {}
|
|
193
|
+
for (const v of TONE_VARS) { const val = cs.getPropertyValue(v).trim(); if (val) vars[v] = val }
|
|
194
|
+
setAmbient(vars)
|
|
195
|
+
}, [shown]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
196
|
+
|
|
197
|
+
const child = asChild && isValidElement(children) ? children : null
|
|
198
|
+
const childRef = child ? childRefOf(child) : undefined
|
|
199
|
+
const setRef = (node) => {
|
|
200
|
+
popover.refs.setReference(node)
|
|
201
|
+
if (typeof childRef === 'function') childRef(node)
|
|
202
|
+
else if (childRef) childRef.current = node
|
|
203
|
+
}
|
|
161
204
|
return (
|
|
162
205
|
<>
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
|
|
206
|
+
{child ? (
|
|
207
|
+
<TooltipNest.Provider value={setCovered}>
|
|
208
|
+
{cloneElement(child, { ...popover.getReferenceProps(child.props), ref: setRef })}
|
|
209
|
+
</TooltipNest.Provider>
|
|
210
|
+
) : (
|
|
211
|
+
<span
|
|
212
|
+
ref={popover.refs.setReference}
|
|
213
|
+
{...popover.getReferenceProps()}
|
|
214
|
+
className={triggerClassName}
|
|
215
|
+
>
|
|
216
|
+
<TooltipNest.Provider value={setCovered}>{children}</TooltipNest.Provider>
|
|
217
|
+
</span>
|
|
218
|
+
)}
|
|
170
219
|
<PopoverPanel
|
|
171
220
|
popover={popover}
|
|
172
221
|
focus={false}
|
|
173
222
|
panel={false}
|
|
174
223
|
className="kol-tooltip"
|
|
224
|
+
style={ambient ?? undefined}
|
|
175
225
|
>
|
|
176
|
-
<span
|
|
226
|
+
<span>{label}</span>
|
|
177
227
|
{shortcut && <span className="kol-tooltip-key">{shortcut}</span>}
|
|
178
228
|
</PopoverPanel>
|
|
179
229
|
</>
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
41
|
+
import { Tooltip } from './Popover.jsx'
|
|
41
42
|
|
|
42
43
|
/* ── coordinates ─────────────────────────────────────────────────────────
|
|
43
44
|
* Pure. No DOM writes. The invariant everything rests on: a cell is a
|
|
@@ -442,14 +443,15 @@ export default function QuadrantSync ({
|
|
|
442
443
|
className="kol-mono-12 text-fg-96 bg-fg-04 border border-fg-16"
|
|
443
444
|
style={{ width: 52, padding: '5px 6px', textAlign: 'center', borderRadius: 4 }}
|
|
444
445
|
/>
|
|
446
|
+
<Tooltip label="Set divisions so cells come out square at the current size" asChild>
|
|
445
447
|
<button
|
|
446
448
|
type="button" onClick={applySquare}
|
|
447
|
-
title="Set divisions so cells come out square at the current size"
|
|
448
449
|
className="kol-helper-10 text-fg-96 border border-fg-16"
|
|
449
450
|
style={{ marginLeft: 'auto', padding: '5px 9px', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}
|
|
450
451
|
>
|
|
451
452
|
square
|
|
452
453
|
</button>
|
|
454
|
+
</Tooltip>
|
|
453
455
|
</div>
|
|
454
456
|
{aspect && (
|
|
455
457
|
<div className="kol-mono-10 text-fg-48" style={{ marginTop: 6 }}>
|
|
@@ -27,3 +27,50 @@ export function parseFrontmatter(raw) {
|
|
|
27
27
|
}
|
|
28
28
|
return metadata
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* splitFrontmatter / joinFrontmatter — the EDITOR's pair (DocumentEditor, 2026-09-26). Where
|
|
33
|
+
* `parseFrontmatter` reads for display (keys lowercased, lists flattened), these round-trip a file:
|
|
34
|
+
* keys keep their case and their order, a list stays a list, and the body is untouched — so opening
|
|
35
|
+
* and saving a document without touching the form changes nothing but what was edited.
|
|
36
|
+
*
|
|
37
|
+
* `fields` is `[[key, value]]`, value a string or an array of strings.
|
|
38
|
+
*/
|
|
39
|
+
const unquote = (v) => {
|
|
40
|
+
if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { try { return JSON.parse(v) } catch { return v.slice(1, -1) } }
|
|
41
|
+
if (v.length > 1 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'")
|
|
42
|
+
return v
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function splitFrontmatter(raw = '') {
|
|
46
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
|
47
|
+
if (!match) return { has: false, fields: [], body: raw }
|
|
48
|
+
const fields = []
|
|
49
|
+
const lines = match[1].split(/\r?\n/)
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const kv = lines[i].match(/^([A-Za-z_][\w -]*):\s*(.*)$/)
|
|
52
|
+
if (!kv) continue
|
|
53
|
+
let value = kv[2].trim()
|
|
54
|
+
if (!value) {
|
|
55
|
+
const items = []
|
|
56
|
+
while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) { i++; items.push(lines[i].replace(/^\s+-\s+/, '').trim()) }
|
|
57
|
+
value = items.length ? items : ''
|
|
58
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
59
|
+
value = value.slice(1, -1).split(',').map((t) => unquote(t.trim())).filter(Boolean)
|
|
60
|
+
} else {
|
|
61
|
+
value = unquote(value)
|
|
62
|
+
}
|
|
63
|
+
fields.push([kv[1], value])
|
|
64
|
+
}
|
|
65
|
+
return { has: true, fields, body: raw.slice(match[0].length) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* bare when YAML reads it back as the same string; quoted otherwise (a colon, a leading #, quotes) */
|
|
69
|
+
const yamlScalar = (v) => (/^[\w./@+-][\w ./@+,-]*$/.test(v) || v === '' ? v : JSON.stringify(v))
|
|
70
|
+
|
|
71
|
+
export function joinFrontmatter(fields, body = '') {
|
|
72
|
+
const rows = fields.filter(([key]) => String(key).trim())
|
|
73
|
+
if (!rows.length) return body
|
|
74
|
+
const yaml = rows.map(([key, v]) => (Array.isArray(v) ? `${key}: [${v.map(yamlScalar).join(', ')}]` : `${key}: ${yamlScalar(String(v ?? ''))}`)).join('\n')
|
|
75
|
+
return `---\n${yaml}\n---\n${body.startsWith('\n') ? body.slice(1) : body}`
|
|
76
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localDrafts — a text file's unsaved edit, in BROWSER memory (media D1 plan v2, user ruling
|
|
3
|
+
* 2026-09-26: "draft saves can rely on browser memory … only what's needed goes to D1"). A draft is
|
|
4
|
+
* work in progress on one device; the database holds what must outlive the device. So the DS keeps
|
|
5
|
+
* drafts itself, keyed by bucket + file key, and no client needs a draft verb.
|
|
6
|
+
*
|
|
7
|
+
* Every write announces itself on `window` (`kol-media-drafts`), so a list elsewhere on the page
|
|
8
|
+
* (a Home shelf, the "unsaved draft" mark) re-reads without prop drilling. Storage can be missing
|
|
9
|
+
* or full (private mode, a quota) — every call degrades to "no draft", never to a throw.
|
|
10
|
+
*/
|
|
11
|
+
const PREFIX = 'kol-media-draft:'
|
|
12
|
+
const EVENT = 'kol-media-drafts'
|
|
13
|
+
const store = () => { try { return globalThis.localStorage ?? null } catch { return null } }
|
|
14
|
+
const k = (bucket, key) => `${PREFIX}${bucket ?? ''}:${key}`
|
|
15
|
+
const announce = () => { try { globalThis.dispatchEvent?.(new Event(EVENT)) } catch { /* no window */ } }
|
|
16
|
+
|
|
17
|
+
export const DRAFTS_EVENT = EVENT
|
|
18
|
+
|
|
19
|
+
/** `{ text, at }` (at = epoch ms) or null */
|
|
20
|
+
export function readDraft(bucket, key) {
|
|
21
|
+
try { const raw = store()?.getItem(k(bucket, key)); return raw ? JSON.parse(raw) : null } catch { return null }
|
|
22
|
+
}
|
|
23
|
+
export function writeDraft(bucket, key, text) {
|
|
24
|
+
try { store()?.setItem(k(bucket, key), JSON.stringify({ text, at: Date.now() })); announce() } catch { /* quota */ }
|
|
25
|
+
}
|
|
26
|
+
export function clearDraft(bucket, key) {
|
|
27
|
+
try { store()?.removeItem(k(bucket, key)); announce() } catch { /* nothing to clear */ }
|
|
28
|
+
}
|
|
29
|
+
/** every draft in a bucket, newest first — `[{ key, at, size }]` */
|
|
30
|
+
export function listDrafts(bucket) {
|
|
31
|
+
const s = store()
|
|
32
|
+
if (!s) return []
|
|
33
|
+
const head = k(bucket, '')
|
|
34
|
+
const out = []
|
|
35
|
+
for (let i = 0; i < s.length; i++) {
|
|
36
|
+
const name = s.key(i)
|
|
37
|
+
if (!name?.startsWith(head)) continue
|
|
38
|
+
try { const d = JSON.parse(s.getItem(name)); out.push({ key: name.slice(head.length), at: d.at, size: d.text?.length ?? 0 }) } catch { /* a foreign value */ }
|
|
39
|
+
}
|
|
40
|
+
return out.sort((a, b) => b.at - a.at)
|
|
41
|
+
}
|
|
42
|
+
/** a file or a folder moved: its drafts follow (a folder is a path ending in `/`) */
|
|
43
|
+
export function moveDrafts(bucket, from, to) {
|
|
44
|
+
const moved = listDrafts(bucket).filter((d) => d.key === from || (from.endsWith('/') && d.key.startsWith(from)))
|
|
45
|
+
for (const d of moved) {
|
|
46
|
+
const draft = readDraft(bucket, d.key)
|
|
47
|
+
try { store()?.removeItem(k(bucket, d.key)); store()?.setItem(k(bucket, to + d.key.slice(from.length)), JSON.stringify(draft)) } catch { /* quota */ }
|
|
48
|
+
}
|
|
49
|
+
if (moved.length) announce()
|
|
50
|
+
}
|