@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.225.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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* title — passes through.
|
|
41
41
|
*/
|
|
42
42
|
import TransparentX from '../utilities/TransparentX'
|
|
43
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
43
44
|
|
|
44
45
|
const SIZE_CLASSES = {
|
|
45
46
|
fill: 'w-full aspect-square',
|
|
@@ -122,11 +123,11 @@ export default function ColorSwatch({
|
|
|
122
123
|
|
|
123
124
|
if (interactive) {
|
|
124
125
|
return (
|
|
126
|
+
<Tooltip label={title} asChild>
|
|
125
127
|
<button
|
|
126
128
|
{...rest}
|
|
127
129
|
type="button"
|
|
128
130
|
onClick={onClick}
|
|
129
|
-
title={title}
|
|
130
131
|
aria-label={rest['aria-label'] ?? hex ?? 'transparent'}
|
|
131
132
|
aria-pressed={selected}
|
|
132
133
|
className={cls}
|
|
@@ -134,12 +135,15 @@ export default function ColorSwatch({
|
|
|
134
135
|
>
|
|
135
136
|
{inner}
|
|
136
137
|
</button>
|
|
138
|
+
</Tooltip>
|
|
137
139
|
)
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
return (
|
|
141
|
-
<
|
|
143
|
+
<Tooltip label={title} asChild>
|
|
144
|
+
<span {...rest} aria-hidden="true" className={cls} style={style}>
|
|
142
145
|
{inner}
|
|
143
146
|
</span>
|
|
147
|
+
</Tooltip>
|
|
144
148
|
)
|
|
145
149
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useContext } from 'react'
|
|
2
2
|
import { CanvasZoomContext } from '../hooks/canvasZoom.js'
|
|
3
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
3
4
|
|
|
4
5
|
/* taxonomy-ok: presentational transform-chrome overlay. It nests no KOL
|
|
5
6
|
* component (pure inline-styled squares + label), so by the letter of the
|
|
@@ -110,9 +111,9 @@ export default function SelectionOverlay({
|
|
|
110
111
|
* the box about its centre. Independent of the resize handles: a path
|
|
111
112
|
* hides those (node-edit owns their geometry) and still rotates. */}
|
|
112
113
|
{rotate && (
|
|
114
|
+
<Tooltip label={rotateTitle} asChild>
|
|
113
115
|
<div
|
|
114
116
|
data-handle="ROT"
|
|
115
|
-
title={rotateTitle}
|
|
116
117
|
style={{
|
|
117
118
|
position: 'absolute',
|
|
118
119
|
left: `calc(50% - ${size / 2}px)`,
|
|
@@ -126,6 +127,7 @@ export default function SelectionOverlay({
|
|
|
126
127
|
pointerEvents: 'auto',
|
|
127
128
|
}}
|
|
128
129
|
/>
|
|
130
|
+
</Tooltip>
|
|
129
131
|
)}
|
|
130
132
|
{showHandles && HANDLE_DIRS.map(({ dir, cursor, x: hx, y: hy }) => (
|
|
131
133
|
<div
|
package/src/index.js
CHANGED
|
@@ -121,6 +121,7 @@ export { default as SpecList } from './molecules/SpecList.jsx'
|
|
|
121
121
|
export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './organisms/SpectrumControls.jsx'
|
|
122
122
|
export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
|
|
123
123
|
export { default as ToolPalette } from './organisms/ToolPalette.jsx'
|
|
124
|
+
export { default as DocumentEditor } from './organisms/DocumentEditor.jsx'
|
|
124
125
|
export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
|
|
125
126
|
export { default as TabsRow } from './molecules/TabsRow.jsx'
|
|
126
127
|
|
|
@@ -165,7 +166,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
|
|
|
165
166
|
/* the full-screen file viewer — public 2026-09-04 (user ruling): it already
|
|
166
167
|
* rendered both mobile viewer views and was module-private, so every consumer
|
|
167
168
|
* needing a phone viewer hand-rolled one. */
|
|
168
|
-
export { MediaInspector } from './organisms/MediaLibraryPages.jsx'
|
|
169
|
+
export { MediaInspector, mediaSettingsSections, SETTINGS_BASE as MEDIA_SETTINGS_BASE } from './organisms/MediaLibraryPages.jsx'
|
|
169
170
|
export { default as MediaLibraryExplorer } from './organisms/MediaLibraryExplorer.jsx'
|
|
170
171
|
export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
|
|
171
172
|
export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
@@ -187,7 +188,8 @@ export { default as RowMenuButton } from './molecules/RowMenuButton.jsx'
|
|
|
187
188
|
export { default as DocPage } from './molecules/DocPage.jsx'
|
|
188
189
|
export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
|
|
189
190
|
export { default as ProfileCard } from './molecules/ProfileCard.jsx'
|
|
190
|
-
export { parseFrontmatter } from './utilities/frontmatter.js'
|
|
191
|
+
export { parseFrontmatter, splitFrontmatter, joinFrontmatter } from './utilities/frontmatter.js'
|
|
192
|
+
export { readDraft, writeDraft, clearDraft, listDrafts, moveDrafts, DRAFTS_EVENT } from './utilities/localDrafts.js'
|
|
191
193
|
export { readCover } from './utilities/id3.js'
|
|
192
194
|
export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegment, groupSegments, groupVariants, posterFor, partition } from './utilities/mediaKinds.js'
|
|
193
195
|
export { nearestRatio, RATIOS } from './utilities/ratios.js'
|
|
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import ColorSwatch from '../atoms/ColorSwatch'
|
|
4
4
|
import Input from '../atoms/Input'
|
|
5
|
-
import { usePopover, PopoverPanel } from '../utilities/Popover'
|
|
5
|
+
import { usePopover, PopoverPanel, Tooltip } from '../utilities/Popover'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* ColorInputRow — swatch chip + `#` hex input row. The single merged form of
|
|
@@ -150,11 +150,11 @@ export default function ColorInputRow({
|
|
|
150
150
|
{chip(24)}
|
|
151
151
|
</button>
|
|
152
152
|
) : isLockToggle ? (
|
|
153
|
+
<Tooltip label={locked ? 'Unlock' : 'Lock'} asChild>
|
|
153
154
|
<button
|
|
154
155
|
type="button"
|
|
155
156
|
onClick={onToggleLock}
|
|
156
157
|
aria-pressed={locked}
|
|
157
|
-
title={locked ? 'Unlock' : 'Lock'}
|
|
158
158
|
className="group relative inline-flex h-6 w-6 shrink-0"
|
|
159
159
|
>
|
|
160
160
|
{chip('stretch')}
|
|
@@ -167,6 +167,7 @@ export default function ColorInputRow({
|
|
|
167
167
|
<Icon name={locked ? 'lock' : 'unlock'} size={12} />
|
|
168
168
|
</span>
|
|
169
169
|
</button>
|
|
170
|
+
</Tooltip>
|
|
170
171
|
) : (
|
|
171
172
|
chip(24)
|
|
172
173
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import { toneClass, TONE_VARS } from '../utilities/tone.js'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
|
-
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
4
|
+
import { MenuDropdownItem, MenuDropdownDivider } from './MenuItem.jsx'
|
|
5
5
|
import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
6
6
|
import { glyphSize, indicatorSize } from '../hooks/glyphLadders.js'
|
|
7
7
|
|
|
@@ -160,7 +160,11 @@ const Dropdown = ({
|
|
|
160
160
|
onOptionHover?.(null)
|
|
161
161
|
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
/* `{ divider: true }` in `options` draws a hairline between groups (FoundationsTones, 2026-09-26 —
|
|
164
|
+
* user: none on top, the filled tones in order, outline and ghost apart). It is never a choice:
|
|
165
|
+
* the lookup, the fallback and the trigger's width all read the choices only. */
|
|
166
|
+
const choices = options.filter((opt) => !opt?.divider)
|
|
167
|
+
const currentOption = choices.find((opt) => opt.value === value) || choices[0]
|
|
164
168
|
|
|
165
169
|
/* A clamped list (Popover caps the panel to the viewport) can open with the
|
|
166
170
|
* checked row past the fold — scroll it into reach. Keyboard focus after
|
|
@@ -168,7 +172,7 @@ const Dropdown = ({
|
|
|
168
172
|
const listRef = useRef(null)
|
|
169
173
|
useEffect(() => {
|
|
170
174
|
if (!isOpen) return
|
|
171
|
-
const idx = options.findIndex((opt) => opt.value === currentOption?.value)
|
|
175
|
+
const idx = options.findIndex((opt) => !opt?.divider && opt.value === currentOption?.value)
|
|
172
176
|
listRef.current?.children[idx]?.scrollIntoView({ block: 'nearest' })
|
|
173
177
|
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
174
178
|
|
|
@@ -215,7 +219,7 @@ const Dropdown = ({
|
|
|
215
219
|
* trigger and list stay one piece at every selection */}
|
|
216
220
|
<span className="kol-dd-label">
|
|
217
221
|
<span>{currentOption?.label}</span>
|
|
218
|
-
{
|
|
222
|
+
{choices.map((option) => (
|
|
219
223
|
<span key={option.value} className="kol-dd-ghost" aria-hidden="true">
|
|
220
224
|
{option.label}
|
|
221
225
|
</span>
|
|
@@ -247,7 +251,8 @@ const Dropdown = ({
|
|
|
247
251
|
{(resolvedVariant === 'primary' || resolvedVariant === 'grey') && <div className="kol-dd-div" />}
|
|
248
252
|
|
|
249
253
|
<div ref={listRef} className="kol-dd-list" role="listbox">
|
|
250
|
-
{options.map((option) => {
|
|
254
|
+
{options.map((option, i) => {
|
|
255
|
+
if (option?.divider) return <MenuDropdownDivider key={`divider-${i}`} />
|
|
251
256
|
const isActive = option.value === currentOption?.value
|
|
252
257
|
return (
|
|
253
258
|
<MenuDropdownItem
|
|
@@ -115,7 +115,11 @@ export default function SectionText({
|
|
|
115
115
|
{headline && <Headline className={cls(`kol-section-text-headline ${headlineClass ?? (HEADLINE_ROLE[headlineSize] ?? HEADLINE_ROLE['heading-02'])}${headlineCase === 'upper' ? ' kol-section-text-caps' : ''}`, 'headline')} style={slotStyle.headline}>{headline}</Headline>}
|
|
116
116
|
{bodyNode && (inlineActions
|
|
117
117
|
? (
|
|
118
|
-
|
|
118
|
+
/* SELF-STRETCH (the Hub review, 2026-09-26 — user, on fxr's /settings: "there should
|
|
119
|
+
* have been space between … on the right edge"). The column is `items-start`, so this
|
|
120
|
+
* row shrank to its content and `justify-between` had no room to act: the cluster sat
|
|
121
|
+
* against the subtitle on every PageHeader with actions. */
|
|
122
|
+
<div className="flex self-stretch items-baseline justify-between gap-6">
|
|
119
123
|
{bodyNode}
|
|
120
124
|
{cluster}
|
|
121
125
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import Button from '../atoms/Button.jsx'
|
|
4
|
-
import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
4
|
+
import { PopoverPanel, usePopover, Tooltip } from '../utilities/Popover.jsx'
|
|
5
5
|
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -44,15 +44,16 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
|
|
|
44
44
|
return (
|
|
45
45
|
<div className={`inline-flex items-center ${className}`.trim()}>
|
|
46
46
|
{current?.icon ? (
|
|
47
|
+
<Tooltip label={current.label}>
|
|
47
48
|
<Button
|
|
48
49
|
variant="ghost"
|
|
49
50
|
size="sm"
|
|
50
51
|
quiet
|
|
51
52
|
iconOnly={current.icon}
|
|
52
53
|
aria-label={current.label}
|
|
53
|
-
title={current.label}
|
|
54
54
|
onClick={() => onAction?.(current.id)}
|
|
55
55
|
/>
|
|
56
|
+
</Tooltip>
|
|
56
57
|
) : (
|
|
57
58
|
<Button variant="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
|
|
58
59
|
{current?.label}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
-
import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
3
|
+
import { PopoverPanel, usePopover, Tooltip } from '../utilities/Popover.jsx'
|
|
4
4
|
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -121,6 +121,7 @@ const SplitToolButton = ({
|
|
|
121
121
|
|
|
122
122
|
return (
|
|
123
123
|
<>
|
|
124
|
+
<Tooltip label={title} asChild>
|
|
124
125
|
<button
|
|
125
126
|
ref={popover.refs.setReference}
|
|
126
127
|
{...popover.getReferenceProps({ onClick: handleTriggerClick })}
|
|
@@ -129,11 +130,11 @@ const SplitToolButton = ({
|
|
|
129
130
|
disabled={disabled}
|
|
130
131
|
aria-pressed={onTrigger ? undefined : active}
|
|
131
132
|
aria-label={label}
|
|
132
|
-
title={title}
|
|
133
133
|
>
|
|
134
134
|
{triggerVariant && <IconC name={triggerVariant.icon} size={glyphSize(size, true)} />}
|
|
135
135
|
<FoldIndicator />
|
|
136
136
|
</button>
|
|
137
|
+
</Tooltip>
|
|
137
138
|
{/* w-max — floats size to content, the menu-family law (2026-08-09). */}
|
|
138
139
|
<PopoverPanel
|
|
139
140
|
popover={popover}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import ColorSwatch from '../atoms/ColorSwatch.jsx'
|
|
4
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
4
5
|
|
|
5
6
|
/*
|
|
6
7
|
* SwatchControls — the Photoshop-style top row of the colour panel, in two
|
|
@@ -107,16 +108,17 @@ export function EyedropPick({ sampleColor, onPick, disabled = false }) {
|
|
|
107
108
|
return (
|
|
108
109
|
<div className="flex items-start gap-1 shrink-0">
|
|
109
110
|
{supported && (
|
|
111
|
+
<Tooltip label="Pick a color from the canvas" asChild>
|
|
110
112
|
<button
|
|
111
113
|
type="button"
|
|
112
114
|
onClick={onPick}
|
|
113
115
|
disabled={disabled}
|
|
114
116
|
aria-label="Eyedropper"
|
|
115
|
-
title="Pick a color from the canvas"
|
|
116
117
|
className="inline-flex cursor-pointer text-fg hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
117
118
|
>
|
|
118
119
|
<Icon name="eyedrop" size={24} />
|
|
119
120
|
</button>
|
|
121
|
+
</Tooltip>
|
|
120
122
|
)}
|
|
121
123
|
<ColorSwatch
|
|
122
124
|
hex={sampleColor}
|
package/src/organisms/Canvas.jsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import { CanvasZoomContext } from '../hooks/canvasZoom.js'
|
|
3
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Canvas — the editor's aspect-ratio stage.
|
|
@@ -628,22 +629,24 @@ export function PanZoomViewport({
|
|
|
628
629
|
{/* Zoom % + fps — matching chips. Zoom (click resets to 100% /
|
|
629
630
|
centered) first, fps to its right, shown while `f` toggles it. */}
|
|
630
631
|
<div className="absolute bottom-3 right-3 z-[3] flex items-center gap-2">
|
|
632
|
+
<Tooltip label="Reset zoom (⌘0)" asChild>
|
|
631
633
|
<button
|
|
632
634
|
type="button"
|
|
633
635
|
onClick={() => setView({ zoom: 1, x: 0, y: 0 })}
|
|
634
636
|
className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
|
|
635
637
|
style={{ opacity: atRest && !showFps ? 0.55 : 1 }}
|
|
636
|
-
title="Reset zoom (⌘0)"
|
|
637
638
|
>
|
|
638
639
|
{Math.round(view.zoom * 100)}%
|
|
639
640
|
</button>
|
|
641
|
+
</Tooltip>
|
|
640
642
|
{showFps && (
|
|
643
|
+
<Tooltip label="Framerate — press F to hide" asChild>
|
|
641
644
|
<span
|
|
642
645
|
className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
|
|
643
|
-
title="Framerate — press F to hide"
|
|
644
646
|
>
|
|
645
647
|
{fps} fps
|
|
646
648
|
</span>
|
|
649
|
+
</Tooltip>
|
|
647
650
|
)}
|
|
648
651
|
</div>
|
|
649
652
|
</div>
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/* taxonomy-ok: organism — composes QuickLookFrame, FullscreenOverlay, Input, Textarea, ViewToggle, Button and KindPreview into one editing surface. */
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import Button from '../atoms/Button.jsx'
|
|
4
|
+
import Input from '../atoms/Input.jsx'
|
|
5
|
+
import Textarea from '../atoms/Textarea.jsx'
|
|
6
|
+
import ViewToggle from '../atoms/ViewToggle.jsx'
|
|
7
|
+
import QuickLookFrame from '../molecules/QuickLookFrame.jsx'
|
|
8
|
+
import KindPreview from '../molecules/KindPreview.jsx'
|
|
9
|
+
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
10
|
+
import { splitFrontmatter, joinFrontmatter } from '../utilities/frontmatter.js'
|
|
11
|
+
import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* DocumentEditor — write a text file: open one, or make a new one (media D1 plan v2, 2026-09-26).
|
|
15
|
+
*
|
|
16
|
+
* The concept is kol-olina's brand notes page (NoteEdit): a title, a markdown body, Write / Preview,
|
|
17
|
+
* attachments from the bucket, a page for writing something NEW rather than a button hidden in a
|
|
18
|
+
* list. Here the document is a FILE in the bucket, so it takes any text kind, and a markdown file's
|
|
19
|
+
* frontmatter gets a form instead of a YAML fence to hand-edit.
|
|
20
|
+
*
|
|
21
|
+
* markdown a FIELDS form (title · description · date · tags suggested, any key allowed) over
|
|
22
|
+
* the body; Write · Split · Preview; Attach inserts a bucket file as a link / image
|
|
23
|
+
* svg text with a live picture beside it
|
|
24
|
+
* the rest text (json · yaml · csv · txt · code) with Split / Preview through KindPreview
|
|
25
|
+
*
|
|
26
|
+
* TWO TIERS, as NoteEdit had them: the DRAFT is browser memory (`localDrafts`, every pause, no
|
|
27
|
+
* network — the user's ruling), the SAVE is explicit (`onSave` / ⌘S). A draft is restored only if it
|
|
28
|
+
* is newer than the saved file (`savedAt`), so a file saved since is never overwritten by an old
|
|
29
|
+
* draft. Revert drops the draft.
|
|
30
|
+
*
|
|
31
|
+
* @param {'edit'|'new'} mode `new` shows the name + type row and calls `onCreate`
|
|
32
|
+
* @param {string} name the file's name (edit) or a starting name (new)
|
|
33
|
+
* @param {string} kind markdown · text · json · yaml · code · svg (edit); new picks it
|
|
34
|
+
* @param {string} text the saved text; `null` while it loads
|
|
35
|
+
* @param {number} savedAt epoch ms of the saved file — a draft older than this is ignored
|
|
36
|
+
* @param {Object} draft `{ bucket, key }` — where the draft lives; omit for no drafts
|
|
37
|
+
* @param {Function} onSave `(text) => Promise` — edit mode
|
|
38
|
+
* @param {Function} onCreate `({ name, text }) => Promise` — new mode; the name carries the extension
|
|
39
|
+
* @param {string} folder new mode: where the file will be made (shown, not edited)
|
|
40
|
+
* @param {Array} assets `[{ key, name, url, contentType }]` — the files markdown's Attach offers; a
|
|
41
|
+
* searchable list inside the editor inserts the pick at the caret
|
|
42
|
+
* @param {Function} onPickAsset `() => Promise<{ name, url, contentType } | null>` — a consumer's own picker instead
|
|
43
|
+
* @param {Function} onClose
|
|
44
|
+
* @param {boolean} inline render IN the page, filling its container, instead of over it (the notes
|
|
45
|
+
* page, media-shell 2026-09-26: the editor is the page's content there,
|
|
46
|
+
* not a window over a list). No scrim, no Escape-to-close; the X still closes
|
|
47
|
+
*/
|
|
48
|
+
const TYPES = [
|
|
49
|
+
{ value: 'md', label: 'MD', kind: 'markdown' },
|
|
50
|
+
{ value: 'txt', label: 'TXT', kind: 'text' },
|
|
51
|
+
{ value: 'json', label: 'JSON', kind: 'json' },
|
|
52
|
+
{ value: 'yaml', label: 'YAML', kind: 'yaml' },
|
|
53
|
+
{ value: 'csv', label: 'CSV', kind: 'text' },
|
|
54
|
+
]
|
|
55
|
+
const today = () => new Date().toISOString().slice(0, 10)
|
|
56
|
+
const STARTER = {
|
|
57
|
+
md: (title) => joinFrontmatter([['title', title], ['date', today()], ['tags', []]], `\n# ${title}\n\n`),
|
|
58
|
+
txt: () => '',
|
|
59
|
+
json: () => '{\n \n}\n',
|
|
60
|
+
yaml: () => '',
|
|
61
|
+
csv: () => 'name,value\n',
|
|
62
|
+
}
|
|
63
|
+
/* the keys a document usually carries, offered as one-click rows — any other key is typed */
|
|
64
|
+
const SUGGESTED = ['title', 'description', 'date', 'tags']
|
|
65
|
+
const DRAFT_PAUSE = 400 // browser memory is free — a short pause, not D1's write budget
|
|
66
|
+
|
|
67
|
+
export default function DocumentEditor({
|
|
68
|
+
mode = 'edit', name: nameProp = '', kind: kindProp, text, savedAt = 0, draft,
|
|
69
|
+
onSave, onCreate, folder = '', assets, onPickAsset, onClose,
|
|
70
|
+
inline = false,
|
|
71
|
+
}) {
|
|
72
|
+
const [picking, setPicking] = useState(false)
|
|
73
|
+
const [pickQuery, setPickQuery] = useState('')
|
|
74
|
+
const isNew = mode === 'new'
|
|
75
|
+
const [name, setName] = useState(isNew ? (nameProp || 'untitled') : nameProp)
|
|
76
|
+
const [type, setType] = useState('md')
|
|
77
|
+
const kind = isNew ? TYPES.find((t) => t.value === type).kind : kindProp
|
|
78
|
+
const [value, setValue] = useState(null)
|
|
79
|
+
const [base, setBase] = useState(null)
|
|
80
|
+
const [status, setStatus] = useState(isNew ? 'Not created yet' : 'Loading…')
|
|
81
|
+
const [view, setView] = useState(kind === 'markdown' || kind === 'svg' ? 'split' : 'write')
|
|
82
|
+
const timer = useRef(null)
|
|
83
|
+
const bodyRef = useRef(null)
|
|
84
|
+
const latest = useRef('')
|
|
85
|
+
|
|
86
|
+
/* load: the saved text, or a newer draft; a new document starts from its type's starter */
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (isNew) {
|
|
89
|
+
const d = draft ? readDraft(draft.bucket, draft.key) : null
|
|
90
|
+
const start = d?.text ?? STARTER.md(name)
|
|
91
|
+
setValue(start); latest.current = start; setBase('')
|
|
92
|
+
if (d) setStatus('Draft restored')
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
if (text == null) return
|
|
96
|
+
const d = draft ? readDraft(draft.bucket, draft.key) : null
|
|
97
|
+
const useDraft = d && d.at > savedAt && d.text !== text
|
|
98
|
+
setBase(text)
|
|
99
|
+
setValue(useDraft ? d.text : text); latest.current = useDraft ? d.text : text
|
|
100
|
+
setStatus(useDraft ? 'Draft restored — not saved to the file' : 'Saved')
|
|
101
|
+
if (d && !useDraft && draft) clearDraft(draft.bucket, draft.key)
|
|
102
|
+
}, [text]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
103
|
+
|
|
104
|
+
const flush = () => {
|
|
105
|
+
if (timer.current == null || !draft) return
|
|
106
|
+
clearTimeout(timer.current); timer.current = null
|
|
107
|
+
if (latest.current === base && !isNew) clearDraft(draft.bucket, draft.key)
|
|
108
|
+
else writeDraft(draft.bucket, draft.key, latest.current)
|
|
109
|
+
}
|
|
110
|
+
useEffect(() => () => flush(), []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
111
|
+
const change = (next) => {
|
|
112
|
+
setValue(next); latest.current = next
|
|
113
|
+
setStatus(isNew ? 'Not created yet' : next === base ? 'Saved' : 'Edited — not saved')
|
|
114
|
+
clearTimeout(timer.current)
|
|
115
|
+
timer.current = setTimeout(flush, DRAFT_PAUSE)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const dirty = value != null && (isNew || value !== base)
|
|
119
|
+
const save = async () => {
|
|
120
|
+
if (!dirty) return
|
|
121
|
+
clearTimeout(timer.current); timer.current = null
|
|
122
|
+
setStatus(isNew ? 'Creating…' : 'Saving…')
|
|
123
|
+
try {
|
|
124
|
+
if (isNew) {
|
|
125
|
+
const file = name.trim().replace(/\.[^./]+$/, '') || 'untitled'
|
|
126
|
+
await onCreate?.({ name: `${file}.${type}`, text: latest.current })
|
|
127
|
+
} else {
|
|
128
|
+
await onSave?.(latest.current)
|
|
129
|
+
setBase(latest.current)
|
|
130
|
+
}
|
|
131
|
+
if (draft) clearDraft(draft.bucket, draft.key)
|
|
132
|
+
setStatus('Saved')
|
|
133
|
+
} catch (e) { setStatus(`Not saved: ${e.message}`) }
|
|
134
|
+
}
|
|
135
|
+
const revert = () => {
|
|
136
|
+
clearTimeout(timer.current); timer.current = null
|
|
137
|
+
if (draft) clearDraft(draft.bucket, draft.key)
|
|
138
|
+
const back = isNew ? STARTER[type](name) : base
|
|
139
|
+
setValue(back); latest.current = back; setStatus(isNew ? 'Not created yet' : 'Saved')
|
|
140
|
+
}
|
|
141
|
+
/* ⌘S anywhere while the editor is open */
|
|
142
|
+
const saveRef = useRef(save)
|
|
143
|
+
saveRef.current = save
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveRef.current() } }
|
|
146
|
+
window.addEventListener('keydown', onKey)
|
|
147
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
148
|
+
}, [])
|
|
149
|
+
|
|
150
|
+
/* markdown: the fields form and the body are one text, split for editing and joined on change */
|
|
151
|
+
const md = kind === 'markdown'
|
|
152
|
+
const parts = useMemo(() => (md && value != null ? splitFrontmatter(value) : null), [md, value])
|
|
153
|
+
const setFields = (fields) => change(joinFrontmatter(fields, parts.body))
|
|
154
|
+
const setBody = (body) => change(parts.has || parts.fields.length ? joinFrontmatter(parts.fields, body) : body)
|
|
155
|
+
|
|
156
|
+
const insertAsset = (a) => {
|
|
157
|
+
const link = (a.contentType ?? '').startsWith('image/') ? `` : `[${a.name}](${a.url})`
|
|
158
|
+
const el = bodyRef.current?.querySelector('textarea')
|
|
159
|
+
const body = parts.body
|
|
160
|
+
const at = el ? el.selectionStart : body.length
|
|
161
|
+
setBody(`${body.slice(0, at)}${link}${body.slice(at)}`)
|
|
162
|
+
setPicking(false)
|
|
163
|
+
}
|
|
164
|
+
const attach = async () => {
|
|
165
|
+
if (!onPickAsset) { setPicking((p) => !p); return }
|
|
166
|
+
const a = await onPickAsset()
|
|
167
|
+
if (a) insertAsset(a)
|
|
168
|
+
}
|
|
169
|
+
/* images first — Attach is mostly "put this picture in the document" */
|
|
170
|
+
const pickList = (assets ?? [])
|
|
171
|
+
.filter((a) => !pickQuery || a.key.toLowerCase().includes(pickQuery.toLowerCase()))
|
|
172
|
+
.sort((a, b) => Number(!(a.contentType ?? '').startsWith('image/')) - Number(!(b.contentType ?? '').startsWith('image/')) || a.key.localeCompare(b.key))
|
|
173
|
+
.slice(0, 60)
|
|
174
|
+
|
|
175
|
+
/* THE NAME CARRIES THE TITLE while the title is still the starter's — the first thing typed names
|
|
176
|
+
* both, as NoteEdit's title named the note; once the title is edited it is left alone */
|
|
177
|
+
const rename = (next) => {
|
|
178
|
+
const was = name
|
|
179
|
+
setName(next)
|
|
180
|
+
if (!md || !parts) return
|
|
181
|
+
const title = parts.fields.find(([k]) => k.toLowerCase() === 'title')
|
|
182
|
+
if (!title || title[1] !== was) return
|
|
183
|
+
const heading = `# ${was}`
|
|
184
|
+
const body = parts.body.startsWith(`\n${heading}`) || parts.body.startsWith(heading) ? parts.body.replace(heading, `# ${next}`) : parts.body
|
|
185
|
+
change(joinFrontmatter(parts.fields.map(([k, v]) => (k === title[0] ? [k, next] : [k, v])), body))
|
|
186
|
+
}
|
|
187
|
+
const close = () => { flush(); onClose?.() }
|
|
188
|
+
const views = [{ value: 'write', label: 'Write' }, { value: 'split', label: 'Split' }, { value: 'preview', label: 'Preview' }]
|
|
189
|
+
const preview = value == null ? null : kind === 'svg'
|
|
190
|
+
? <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(value)}`} alt="" className="max-w-full max-h-full object-contain m-auto" />
|
|
191
|
+
: <KindPreview o={{ key: name || 'document' }} text={value} kind={kind} />
|
|
192
|
+
|
|
193
|
+
const frame = (
|
|
194
|
+
<QuickLookFrame className={inline ? 'kol-quicklook--inline' : ''} title={isNew ? `New document${folder ? ` in ${folder}` : ''}` : name} meta={status} onClose={close}
|
|
195
|
+
actions={(
|
|
196
|
+
<span className="flex items-center gap-2">
|
|
197
|
+
<ViewToggle variant="text" viewMode={view} onViewChange={setView} options={views} />
|
|
198
|
+
{md && (onPickAsset || assets?.length > 0) && <Button variant="nav" size="sm" iconLeft="image" onClick={attach} aria-pressed={picking}>Attach</Button>}
|
|
199
|
+
{!isNew && value !== base && value != null && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
|
|
200
|
+
<Button variant="secondary" size="sm" onClick={save} disabled={!dirty}>{isNew ? 'Create' : 'Save'}</Button>
|
|
201
|
+
</span>
|
|
202
|
+
)}>
|
|
203
|
+
{/* the window's own caps, inline — Tailwind does not generate arbitrary values from package source */}
|
|
204
|
+
<div className="kol-doc-editor flex flex-col" style={inline ? { width: '100%', height: '100%' } : { width: 'min(1200px, var(--kol-ql-max-w))', height: 'min(760px, var(--kol-ql-media-h))' }}>
|
|
205
|
+
{isNew && (
|
|
206
|
+
<div className="flex flex-wrap items-center gap-3 p-4 border-b" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
207
|
+
<Input variant="filled" size="sm" value={name} aria-label="File name" placeholder="File name"
|
|
208
|
+
onChange={(e) => rename(e.target.value)} className="min-w-0 flex-1" />
|
|
209
|
+
<ViewToggle variant="text" viewMode={type} options={TYPES.map(({ value: v, label }) => ({ value: v, label }))}
|
|
210
|
+
onViewChange={(t) => { setType(t); const s = STARTER[t](name); setValue(s); latest.current = s; setView(t === 'md' ? 'split' : 'write') }} />
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
<div className="flex flex-1 min-h-0">
|
|
214
|
+
{view !== 'preview' && (
|
|
215
|
+
<div ref={bodyRef} className="flex flex-col gap-4 p-4 min-w-0 overflow-y-auto" style={{ flex: '1 1 0' }}>
|
|
216
|
+
{md && parts && <FieldsForm fields={parts.fields} onChange={setFields} />}
|
|
217
|
+
{picking && (
|
|
218
|
+
<div className="flex flex-col gap-2 border rounded p-2" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
219
|
+
<Input variant="filled" size="xs" value={pickQuery} placeholder="Find a file to attach" aria-label="Find a file to attach"
|
|
220
|
+
onChange={(e) => setPickQuery(e.target.value)} autoFocus />
|
|
221
|
+
<div className="flex flex-col overflow-y-auto" style={{ maxHeight: 200 }}>
|
|
222
|
+
{pickList.length === 0 && <span className="kol-mono-12 text-fg-48 p-2">No file matches.</span>}
|
|
223
|
+
{pickList.map((a) => (
|
|
224
|
+
<button key={a.key} type="button" onClick={() => insertAsset(a)}
|
|
225
|
+
className="kol-mono-12 text-left px-2 py-1 rounded text-fg-default hover:bg-oq-04 truncate">{a.key}</button>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
{value == null
|
|
231
|
+
? <p className="kol-mono-12 text-fg-48">Loading…</p>
|
|
232
|
+
: <Textarea variant="filled" size="md" rows={18} axis="vertical" className="w-full flex-1 kol-mono-12"
|
|
233
|
+
value={md ? parts.body : value} onChange={(e) => (md ? setBody(e.target.value) : change(e.target.value))}
|
|
234
|
+
spellCheck={kind === 'markdown' || kind === 'text'} aria-label="Document text"
|
|
235
|
+
placeholder={md ? 'Write in markdown — # heading, **bold**, - list' : ''} />}
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
{view !== 'write' && (
|
|
239
|
+
<div className="p-4 min-w-0 overflow-auto border-l" style={{ flex: '1 1 0', borderColor: 'var(--kol-oq-08)' }}>
|
|
240
|
+
{preview}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</QuickLookFrame>
|
|
246
|
+
)
|
|
247
|
+
return inline ? frame : <FullscreenOverlay open onClose={close} closeButton={false} scrim>{frame}</FullscreenOverlay>
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* THE FRONTMATTER AS FIELDS. One row per key; a list value (tags) is typed comma-separated. The
|
|
251
|
+
* suggested keys a document has not got yet are one click away; any other key is typed. */
|
|
252
|
+
function FieldsForm({ fields, onChange }) {
|
|
253
|
+
const set = (i, key, v) => onChange(fields.map((f, j) => (j === i ? [key, v] : f)))
|
|
254
|
+
const missing = SUGGESTED.filter((s) => !fields.some(([k]) => k.toLowerCase() === s))
|
|
255
|
+
return (
|
|
256
|
+
<div className="flex flex-col gap-2">
|
|
257
|
+
<span className="kol-mono-12 text-fg-48">Fields</span>
|
|
258
|
+
{fields.map(([key, v], i) => (
|
|
259
|
+
<div key={i} className="flex items-center gap-2">
|
|
260
|
+
<Input variant="filled" size="xs" value={key} aria-label="Field name" className="w-28 shrink-0"
|
|
261
|
+
onChange={(e) => set(i, e.target.value, v)} />
|
|
262
|
+
{Array.isArray(v) || key.toLowerCase() === 'tags' ? (
|
|
263
|
+
/* a list commits on Enter / blur — re-deriving it per keystroke would eat the comma you just typed */
|
|
264
|
+
<Input variant="filled" size="xs" className="flex-1 min-w-0" aria-label={`${key || 'Field'} value`}
|
|
265
|
+
value={Array.isArray(v) ? v.join(', ') : v} placeholder="comma, separated"
|
|
266
|
+
onCommit={(raw) => set(i, key, raw.split(',').map((t) => t.trim()).filter(Boolean))} />
|
|
267
|
+
) : (
|
|
268
|
+
<Input variant="filled" size="xs" className="flex-1 min-w-0" aria-label={`${key || 'Field'} value`}
|
|
269
|
+
value={v} onChange={(e) => set(i, key, e.target.value)} />
|
|
270
|
+
)}
|
|
271
|
+
<Button variant="nav" size="xs" iconOnly="x" aria-label={`Remove ${key}`} onClick={() => onChange(fields.filter((_, j) => j !== i))} />
|
|
272
|
+
</div>
|
|
273
|
+
))}
|
|
274
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
275
|
+
{missing.map((s) => (
|
|
276
|
+
<Button key={s} variant="nav" size="xs" iconLeft="plus" onClick={() => onChange([...fields, [s, s === 'tags' ? [] : s === 'date' ? today() : '']])}>{s}</Button>
|
|
277
|
+
))}
|
|
278
|
+
<Button variant="nav" size="xs" iconLeft="plus" onClick={() => onChange([...fields, ['', '']])}>Field</Button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
@@ -3,6 +3,7 @@ import Button from '../atoms/Button.jsx'
|
|
|
3
3
|
import Dropdown from '../molecules/Dropdown.jsx'
|
|
4
4
|
import LabeledControl from '../molecules/LabeledControl.jsx'
|
|
5
5
|
import Slider from '../molecules/Slider.jsx'
|
|
6
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
6
7
|
|
|
7
8
|
const deg = (r) => Math.round(((r || 0) * 180) / Math.PI)
|
|
8
9
|
const rad = (d) => (d * Math.PI) / 180
|
|
@@ -117,7 +118,7 @@ export default function KeyframeEditor({
|
|
|
117
118
|
</div>
|
|
118
119
|
<div className="flex gap-2">
|
|
119
120
|
<Button variant="primary" size="sm" className="flex-1" onClick={onAdd}>Add @ playhead</Button>
|
|
120
|
-
<Button variant="ghost" size="sm"
|
|
121
|
+
<Tooltip label="Delete keyframe"><Button variant="ghost" size="sm" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button></Tooltip>
|
|
121
122
|
</div>
|
|
122
123
|
|
|
123
124
|
<span className="kol-helper-10 text-meta">Pose</span>
|