@xiongtao.xt/tau-plugins 0.32.0 → 0.33.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.33.0
4
+
5
+ - 新增媒体组件 `MediaImage`、`MediaVideo`、`MediaAudio`:节点里的本地路径、远程地址和共享文档资源按 Tau 自带图片、视频、音频块的方式解析和缓存。
6
+ - 新增 `useResource(url, { kind })`,自己绘制媒体时取得可加载的地址;`useResourceUpload()` 把文件存进用户当前的资源存储并返回地址,只读编辑器与博客中为 `undefined`。
7
+ - 新增 `useMediaAppearance()`,读取用户设置(博客中为发布设置)的媒体默认圆角与阴影。
8
+ - 新增 `useElement(element)`:读写当前节点(`readOnly`、`update`、`remove`),外框里的块还能取得 `selected`、`select()`、`openMenu(position)`。`useInlineSource` 建在它之上。
9
+ - 节点声明新增 `frame`:void 块的公共外框,提供点选描边、右键菜单、选中时删除与声明的按键命令、末尾「添加段落」;声明后 `render` 只渲染内容。
10
+ - 新增块属性条 `BlockToolbar`、`BlockToolbarButton`、`BlockToolbarGap`,外观与自带图片块一致。
11
+ - 新增 `useMediaViewer(element)`,打开文档图片查看器并定位到该节点的第几张图。
12
+ - `@tau/ui` 新增 `Sortable`,用指针事件拖动排序,手柄支持方向键。
13
+ - 节点声明新增 `resources`:写明节点里哪些字段是图片、视频、音频或文件。导出打包、共享给协作者时上传本地文件、灯箱与 EPUB 取图都只按这份声明找资源;声明随笔记库保存。
14
+
3
15
  ## 0.32.0
4
16
 
5
17
  - 插件 Skill 改为包内目录:在插件项目根目录放 `skills/<id>/SKILL.md`,可附带参考资料和脚本,构建时打进插件包并写入 manifest 的 `skills` 与文件摘要。名称、描述与调用方式取自 SKILL.md 的 front matter。
package/docs/editor.md CHANGED
@@ -134,6 +134,7 @@ export default definePlugin({
134
134
  | `drag` | 可选。为 `true` 时显示拖动手柄,用户可以拖动这个块。不声明就不能拖动 |
135
135
  | `isEmpty` | 可选。判断节点是否为空。内容保存在自定义字段(如源码)里时,用它告诉 Tau 什么情况下算空块 |
136
136
  | `behaviors` | 可选。光标在节点内时,接管回车、退格、输入等行为,见[行为](#行为) |
137
+ | `resources` | 可选。节点里哪些字段是图片、视频、音频或文件,见[声明资源字段](#声明资源字段) |
137
138
 
138
139
  ### 渲染组件
139
140
 
@@ -154,13 +155,13 @@ render: ({ attributes, element, children }) => (
154
155
 
155
156
  ### 修改节点字段
156
157
 
157
- 组件里需要修改节点数据(例如保存用户调整后的参数)时,使用 `useSourceBlock` 返回的 `update`:
158
+ 组件里需要修改节点数据(例如保存用户调整后的参数)时,使用 `useElement` 返回的 `update`:
158
159
 
159
160
  ```tsx
160
- import { useSourceBlock } from "@tau/plugins";
161
+ import { useElement } from "@tau/plugins";
161
162
 
162
163
  function Counter({ element }: { element: CounterElement }) {
163
- const { update } = useSourceBlock(element);
164
+ const { update } = useElement(element);
164
165
  return (
165
166
  <button
166
167
  onMouseDown={event => event.preventDefault()}
@@ -174,6 +175,78 @@ function Counter({ element }: { element: CounterElement }) {
174
175
 
175
176
  `update` 把传入的字段合并进节点,这次修改会进入撤销记录并同步给协作者。
176
177
 
178
+ `useElement(element)` 返回:
179
+
180
+ | 字段 | 说明 |
181
+ | -------------------- | ------------------------------------------------------------------ |
182
+ | `readOnly` | 编辑器是否只读。只读时 `update`、`remove` 不会写入 |
183
+ | `update(properties)` | 把字段合并进节点 |
184
+ | `remove()` | 删除节点 |
185
+ | `selected` | 声明了[外框](#块的外框)的块是否被点选 |
186
+ | `select()` | 选中外框里的块 |
187
+ | `openMenu(position)` | 在指定位置(视口坐标)打开块菜单,条目与右键、块手柄相同;需要外框 |
188
+
189
+ 请始终传入当前渲染拿到的 `element`。节点被替换或组件卸载后,旧对象上的写入会被忽略,不会改到别的块。
190
+
191
+ ### 块的外框
192
+
193
+ 图表、画廊这类整块由组件渲染的节点(`isVoid: true` 的块),在节点声明上加 `frame` 即可得到 Tau 块的公共交互:点击选中并描边、点到别处取消、右键打开块菜单、选中时 Delete / Backspace 删除块并留下空段落、Esc 取消选中,以及块末尾的「添加段落」。
194
+
195
+ ```tsx
196
+ defineEditorElement({
197
+ type: "yourname.chart",
198
+ kind: "block",
199
+ isVoid: true,
200
+ frame: { keys: { Enter: "yourname.chart.edit" } },
201
+ render: Chart,
202
+ });
203
+ ```
204
+
205
+ | 字段 | 说明 |
206
+ | ----------------- | ------------------------------------------------------------------- |
207
+ | `keys` | 可选。选中时的按键对应的命令 id,命令收到这个节点(同菜单里的命令) |
208
+ | `appendParagraph` | 可选。为 `false` 时不显示末尾的「添加段落」 |
209
+
210
+ 声明外框后,`render` 只渲染内容:收到的 `attributes` 是空对象、`children` 为空,交给外框处理。组件里用 `useElement(element).selected` 决定是否显示属性条。
211
+
212
+ ### 块的属性条
213
+
214
+ 选中块后浮在块上方的属性条,用 `BlockToolbar`、`BlockToolbarButton`、`BlockToolbarGap` 组成,外观和 Tau 自带的图片块一致。上方放不下时自动翻到下方,点击按钮不会让编辑器失去光标。
215
+
216
+ ```tsx
217
+ import { BlockToolbar, BlockToolbarButton, BlockToolbarGap, useElement } from "@tau/plugins";
218
+
219
+ function Chart({ element }: { element: ChartElement }) {
220
+ const { selected, readOnly, update, openMenu } = useElement(element);
221
+ return (
222
+ <div className="relative">
223
+ {selected && !readOnly && (
224
+ <BlockToolbar label="图表属性">
225
+ <BlockToolbarButton
226
+ title="柱状图"
227
+ active={element.kind === "bar"}
228
+ icon={<BarChart3 size={15} />}
229
+ onClick={() => update({ kind: "bar" })}
230
+ />
231
+ <BlockToolbarGap />
232
+ <BlockToolbarButton
233
+ title="更多"
234
+ icon={<MoreHorizontal size={15} />}
235
+ onClick={event => {
236
+ const rect = event.currentTarget.getBoundingClientRect();
237
+ openMenu({ x: rect.left, y: rect.bottom + 6 });
238
+ }}
239
+ />
240
+ </BlockToolbar>
241
+ )}
242
+ <ChartView data={element.data} />
243
+ </div>
244
+ );
245
+ }
246
+ ```
247
+
248
+ `BlockToolbarButton` 的 `title` 是按钮名称(纯图标按钮悬停时显示),`label` 是显示在图标旁的文字,`shortcut` 在提示里显示快捷键,`active` 表示当前选中的选项。
249
+
177
250
  ## 扩展能声明的内容
178
251
 
179
252
  `defineEditorExtension` 接收:
@@ -420,6 +493,66 @@ function DiagramBlock({ element, children }: { element: DiagramElement; children
420
493
 
421
494
  行内的源码输入弹层由你用 `@tau/ui` 组件实现。只读时只显示预览,不打开编辑弹层;只读状态下 `update` 和 `remove` 不会写入文档。
422
495
 
496
+ ## 图片、视频与音频
497
+
498
+ 节点里存的图片、视频、音频地址可能是本地路径、远程地址或共享文档里的资源,不能直接交给 `<img>`。用宿主的媒体组件显示,解析方式与 Tau 自带的图片块相同:本地路径转成可加载的地址,远程资源按用户的缓存设置下载,共享文档里的资源经协作服务解析。
499
+
500
+ ```tsx
501
+ import { MediaImage, useMediaAppearance } from "@tau/plugins";
502
+
503
+ function Photo({ element }: { element: PhotoElement }) {
504
+ const appearance = useMediaAppearance();
505
+ return (
506
+ <figure contentEditable={false} data-radius={element.borderRadius ?? appearance?.borderRadius}>
507
+ <MediaImage url={element.url} alt={element.caption} />
508
+ </figure>
509
+ );
510
+ }
511
+ ```
512
+
513
+ | 接口 | 用途 |
514
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
515
+ | `MediaImage` | 显示图片,参数为 `url`、`alt`、`className`、`style`、`loading`、`onLoadError` 等 |
516
+ | `MediaVideo` / `MediaAudio` | 宿主的视频、音频播放器,参数为 `src` 与原生媒体属性 |
517
+ | `useResource(url, { kind })` | 自己绘制时使用:返回 `src`、`status`(`loading` / `ready` / `error`)与 `retry()`,元素加载失败时调用 `retry` 重新解析 |
518
+ | `useResourceUpload()` | 返回上传函数,把用户选的文件存进当前的资源存储并返回可写入节点的地址;只读编辑器和博客里为 `undefined` |
519
+ | `useMediaAppearance()` | 用户设置的媒体默认圆角与阴影(`none` / `sm` / `md` / `lg`);博客里取发布设置。节点自己有设置时以节点为准 |
520
+
521
+ `kind` 取 `image`、`video`、`audio`、`file`,决定远程资源的缓存方式。上传的文件不需要自己判断本地还是远端存储,也不要把文件内容写进节点。
522
+
523
+ ### 查看图片
524
+
525
+ `useMediaViewer(element)` 返回一个函数,调用后打开 Tau 的图片查看器,浏览文档里的全部图片,并定位到这个节点的第 `index` 张(按[声明的资源字段](#声明资源字段)计,从 0 开始)。
526
+
527
+ ```tsx
528
+ const openViewer = useMediaViewer(element);
529
+ <MediaImage url={item.url} onDoubleClick={() => openViewer(index)} />;
530
+ ```
531
+
532
+ ### 声明资源字段
533
+
534
+ 节点里存了图片、视频、音频或文件地址时,在节点声明上用 `resources` 写明是哪些字段。Tau 只按这份声明找资源:导出打包、共享给协作者时上传本地文件、灯箱里浏览图片、EPUB 取图,都靠它。没有声明的字段按普通文字原样保留,本地图片不会随内容导出,也不会上传给协作者。
535
+
536
+ ```tsx
537
+ defineEditorElement({
538
+ type: "yourname.gallery",
539
+ kind: "block",
540
+ isVoid: true,
541
+ render: Gallery,
542
+ resources: [{ path: "images[].url", kind: "image", alt: "desc", caption: "desc" }],
543
+ });
544
+ ```
545
+
546
+ | 字段 | 说明 |
547
+ | --------- | ---------------------------------------------------------------------------------------- |
548
+ | `path` | 资源字段的路径。`url` 是节点上的字段,`images[].url` 表示 `images` 数组每一项的 `url` |
549
+ | `kind` | `image`、`video`、`audio` 或 `file` |
550
+ | `preview` | 可选。同一资源的显示版本(如裁剪结果)所在的字段,与 `path` 末段同层,同样会被导出和上传 |
551
+ | `alt` | 可选。替代文字所在的字段,与 `path` 末段同层,灯箱里显示 |
552
+ | `caption` | 可选。说明文字所在的字段,与 `path` 末段同层,灯箱里显示 |
553
+
554
+ 声明会随笔记库保存,插件停用或在没装插件的设备上,这些资源照样能导出和共享。已经有文档用了的字段不要改名;新增资源字段时补进声明即可。
555
+
423
556
  ## 动态 Tailwind 样式
424
557
 
425
558
  如果节点的源码里写了 Tailwind 类名(比如用户写的组件演示),在扩展上声明哪个字段是源码:
package/docs/ui.md CHANGED
@@ -318,12 +318,13 @@ export function Help() {
318
318
  }
319
319
  ```
320
320
 
321
- | 组件 | 常用属性 |
322
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
323
- | `Button` | `onClick`、`disabled`、`loading`、`loadingText`;`variant` 可选 `primary`、`secondary`、`soft`、`ghost`、`danger`、`danger-soft` |
324
- | `Panel` | 内容容器 |
325
- | `Dialog` | `open`、`onOpenChange`、`title`,`footer` 放操作按钮 |
326
- | `DialogBody` | 弹窗正文 |
321
+ | 组件 | 常用属性 |
322
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
323
+ | `Button` | `onClick`、`disabled`、`loading`、`loadingText`;`variant` 可选 `primary`、`secondary`、`soft`、`ghost`、`danger`、`danger-soft` |
324
+ | `Panel` | 内容容器 |
325
+ | `Dialog` | `open`、`onOpenChange`、`title`,`footer` 放操作按钮 |
326
+ | `DialogBody` | 弹窗正文 |
327
+ | `Sortable` | 拖动排序:`items`、`getKey`、`onMove(from, to)`、`renderItem(item, state)`,把 `state.handleProps` 展开到拖动手柄上;手柄获得焦点时方向键前后移动 |
327
328
 
328
329
  完整的组件列表和属性见 `@tau/ui` 的类型提示。
329
330
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiongtao.xt/tau-plugins",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,6 +3,36 @@ import {
3
3
  validatePluginManifest
4
4
  } from "./client/chunk-SVY5LBAT.js";
5
5
 
6
+ // packages/db-schema/content-resources.ts
7
+ var CONTENT_RESOURCE_KINDS = [
8
+ "image",
9
+ "video",
10
+ "audio",
11
+ "file"
12
+ ];
13
+ var UNSAFE_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
14
+ var SEGMENT = /^[A-Za-z_$][\w$-]*(\[\])?$/;
15
+ var FIELD = /^[A-Za-z_$][\w$-]*$/;
16
+ var MAX_SPECS = 16;
17
+ var MAX_PATH_LENGTH = 128;
18
+ var isField = (value) => typeof value === "string" && FIELD.test(value) && !UNSAFE_SEGMENTS.has(value);
19
+ function isValidPath(path) {
20
+ if (typeof path !== "string" || !path || path.length > MAX_PATH_LENGTH) return false;
21
+ const parts = path.split(".");
22
+ return parts.every(
23
+ (part, index) => SEGMENT.test(part) && !UNSAFE_SEGMENTS.has(part.replace(/\[\]$/, "")) && (index < parts.length - 1 || !part.endsWith("[]"))
24
+ );
25
+ }
26
+ function isContentResourceSpec(value) {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
28
+ const spec = value;
29
+ return isValidPath(spec.path) && CONTENT_RESOURCE_KINDS.includes(spec.kind) && ["preview", "alt", "caption"].every((key) => spec[key] === void 0 || isField(spec[key])) && Object.keys(spec).every((key) => ["path", "kind", "preview", "alt", "caption"].includes(key));
30
+ }
31
+ function assertContentResourceSpecs(value) {
32
+ if (!Array.isArray(value) || value.length > MAX_SPECS || !value.every(isContentResourceSpec))
33
+ throw new Error("\u8D44\u6E90\u58F0\u660E\u65E0\u6548");
34
+ }
35
+
6
36
  // packages/plugins/src/rendering.ts
7
37
  var PLUGIN_RENDER_VERSION = 1;
8
38
  function evaluatePluginPackage(artifact, modules) {
@@ -49,6 +79,13 @@ function getPluginNodeTypes(definition) {
49
79
  for (const element of extension.elements ?? []) {
50
80
  if (!element.type || types.includes(element.type))
51
81
  throw new Error(`\u63D2\u4EF6\u8282\u70B9\u7C7B\u578B\u4E3A\u7A7A\u6216\u91CD\u590D\uFF1A${element.type}`);
82
+ if (element.resources !== void 0) {
83
+ try {
84
+ assertContentResourceSpecs(element.resources);
85
+ } catch {
86
+ throw new Error(`\u63D2\u4EF6\u8282\u70B9\u8D44\u6E90\u58F0\u660E\u65E0\u6548\uFF1A${element.type}`);
87
+ }
88
+ }
52
89
  types.push(element.type);
53
90
  }
54
91
  }
@@ -0,0 +1,76 @@
1
+ export type ContentResourceKind = "image" | "video" | "audio" | "file";
2
+ export declare const CONTENT_RESOURCE_KINDS: readonly ContentResourceKind[];
3
+ export interface ContentResourceSpec {
4
+ /** 字段路径:`url`、`images[].url`;`[]` 表示数组的每一项 */
5
+ path: string;
6
+ kind: ContentResourceKind;
7
+ /** 同一资源的显示版本(如裁剪结果),与 path 末段同层的字段名,同样参与上传与改写 */
8
+ preview?: string;
9
+ /** 替代文字字段,与 path 末段同层 */
10
+ alt?: string;
11
+ /** 说明文字字段,与 path 末段同层 */
12
+ caption?: string;
13
+ }
14
+ /** 节点类型 → 资源字段 */
15
+ export type ContentResourceDeclarations = Readonly<Record<string, readonly ContentResourceSpec[]>>;
16
+ /** 宿主内置节点的资源字段 */
17
+ export declare const BUILTIN_CONTENT_RESOURCES: {
18
+ readonly image: readonly [{
19
+ readonly path: "url";
20
+ readonly kind: "image";
21
+ readonly preview: "previewUrl";
22
+ readonly alt: "alt";
23
+ readonly caption: "description";
24
+ }];
25
+ readonly "inline-image": readonly [{
26
+ readonly path: "url";
27
+ readonly kind: "image";
28
+ readonly alt: "alt";
29
+ }];
30
+ readonly audio: readonly [{
31
+ readonly path: "src";
32
+ readonly kind: "audio";
33
+ }];
34
+ readonly video: readonly [{
35
+ readonly path: "src";
36
+ readonly kind: "video";
37
+ }];
38
+ readonly "file-attachment": readonly [{
39
+ readonly path: "filePath";
40
+ readonly kind: "file";
41
+ }, {
42
+ readonly path: "localFilePath";
43
+ readonly kind: "file";
44
+ }];
45
+ };
46
+ export declare function isContentResourceSpec(value: unknown): value is ContentResourceSpec;
47
+ /** 校验来自插件或其他设备的声明;形状不对时抛错 */
48
+ export declare function assertContentResourceSpecs(value: unknown): asserts value is readonly ContentResourceSpec[];
49
+ /** 合并多份声明,后者覆盖同类型的前者 */
50
+ export declare function mergeContentResources(...sources: readonly ContentResourceDeclarations[]): ContentResourceDeclarations;
51
+ /** 资源所在的对象(字段的父对象)及字段名 */
52
+ export interface ContentResourceSlot {
53
+ spec: ContentResourceSpec;
54
+ owner: Record<string, unknown>;
55
+ field: string;
56
+ }
57
+ /** 按声明列出一个节点里的资源位置(不深入子节点) */
58
+ export declare function contentResourceSlots(node: unknown, specs: readonly ContentResourceSpec[] | undefined): ContentResourceSlot[];
59
+ /** 资源位置上实际的地址:主地址与显示版本,空串跳过 */
60
+ export declare function contentResourceValues(slot: ContentResourceSlot): string[];
61
+ export interface ContentResourceEntry {
62
+ node: Record<string, unknown>;
63
+ slot: ContentResourceSlot;
64
+ }
65
+ /** 按文档顺序遍历节点树里所有声明了资源的位置 */
66
+ export declare function forEachContentResource(nodes: readonly unknown[], declarations: ContentResourceDeclarations, visit: (entry: ContentResourceEntry) => void): void;
67
+ /** 收集节点树里的资源地址(去重,保持首次出现的顺序) */
68
+ export declare function collectContentResources(nodes: readonly unknown[], declarations: ContentResourceDeclarations): {
69
+ url: string;
70
+ kind: ContentResourceKind;
71
+ }[];
72
+ export type ContentResourceMapper = (url: string, kind: ContentResourceKind) => string | undefined;
73
+ /** 不可变地改写一个节点里声明的资源(不深入子节点);返回 undefined 表示保留原值 */
74
+ export declare function mapNodeResources<T>(node: T, specs: readonly ContentResourceSpec[] | undefined, mapper: ContentResourceMapper): T;
75
+ /** 不可变地改写整棵节点树里声明的资源 */
76
+ export declare function mapContentResources<T>(nodes: readonly T[], declarations: ContentResourceDeclarations, mapper: ContentResourceMapper): T[];
@@ -1,3 +1,3 @@
1
1
  /** 无 React/DOM 运行时代码的声明入口,供应用 SDK 适配现有扩展协议。 */
2
2
  export { defineExtension, defineElement } from "./src/core/api/extension";
3
- export type { EditorExtension, ElementSpec, ElementRenderProps } from "./src/core/api/extension";
3
+ export type { EditorExtension, ElementSpec, ElementRenderProps, ElementFrame, } from "./src/core/api/extension";
@@ -1,4 +1,4 @@
1
- import type { ReactNode, ReactElement } from "react";
1
+ import type { MouseEvent, ReactNode, ReactElement } from "react";
2
2
  import type { LucideIcon } from "lucide-react";
3
3
  import type { Editor, Element, Path } from "slate";
4
4
  import type { ElementBehaviors, ParagraphElement, BlockElement } from "./api";
@@ -35,6 +35,46 @@ export interface SourceEditorRuntime {
35
35
  insertSourceBlock(editor: Editor, element: BlockElement, line?: number): Path | undefined;
36
36
  sourceBlockParagraphBehavior(type: string): ElementBehaviors<ParagraphElement>;
37
37
  }
38
+ export interface ElementAccess {
39
+ readOnly: boolean;
40
+ /** 外框里的块是否被点选;未声明外框时恒为 false */
41
+ selected: boolean;
42
+ /** 字段合并进节点,进入撤销记录并同步给协作者;只读、节点已被替换或卸载后不写入 */
43
+ update(properties: Record<string, unknown>): void;
44
+ remove(): void;
45
+ /** 选中外框里的块 */
46
+ select(): void;
47
+ /** 在指定位置打开块菜单;需要外框 */
48
+ openMenu(position: {
49
+ x: number;
50
+ y: number;
51
+ }): void;
52
+ }
53
+ export interface BlockToolbarProps {
54
+ /** 无障碍名称(如「画廊属性」) */
55
+ label: string;
56
+ className?: string;
57
+ children: ReactNode;
58
+ }
59
+ export interface BlockToolbarButtonProps {
60
+ /** 按钮名称:图标按钮的提示与无障碍名称 */
61
+ title: string;
62
+ shortcut?: string;
63
+ /** 显示在图标旁的文字(主操作用) */
64
+ label?: string;
65
+ icon?: ReactNode;
66
+ active?: boolean;
67
+ mono?: boolean;
68
+ onClick(event: MouseEvent<HTMLButtonElement>): void;
69
+ }
70
+ export interface ElementRuntime {
71
+ useElement(element: Element): ElementAccess;
72
+ /** 打开文档图片查看器,定位到该元素的第 index 个图片资源(按资源声明计) */
73
+ useMediaViewer(element: Element): (index?: number) => void;
74
+ BlockToolbar(props: BlockToolbarProps): ReactElement;
75
+ BlockToolbarButton(props: BlockToolbarButtonProps): ReactElement;
76
+ BlockToolbarGap(): ReactElement;
77
+ }
38
78
  export interface InlineSourceRuntime {
39
79
  useInlineSource(element: Element): {
40
80
  readOnly: boolean;
@@ -9,6 +9,7 @@ import type { ElementDrag } from "./drag";
9
9
  import type { ElementIngestSpec } from "./ingest";
10
10
  import type { ElementCopy } from "./copy";
11
11
  import type { DecorationSpec, MarkSpec } from "./marks";
12
+ import type { ContentResourceSpec } from "../../../../db-schema/content-resources";
12
13
  /**
13
14
  * 扩展 API v2(01 §5.1):声明式描述「这个扩展贡献了什么」,内核负责装配、隔离与分发。
14
15
  * 所有扩展都是 defineExtension 的产物(旧 IExtension 适配层已删除,ER-019)。
@@ -35,6 +36,16 @@ export interface ElementEmptyContext {
35
36
  /** 使用当前编辑器已安装的声明判断子节点;未知或失败时保留。 */
36
37
  isEmpty(node: Descendant): boolean;
37
38
  }
39
+ /**
40
+ * void 块的公共外框:点选描边、右键菜单、选中时 Delete / Backspace 删除与 Escape 取消、末尾「添加段落」。
41
+ * 声明后 render 只渲染内容(收到的 attributes 为空对象、children 为 null)。
42
+ */
43
+ export interface ElementFrame {
44
+ /** 末尾的「添加段落」,默认显示 */
45
+ appendParagraph?: boolean;
46
+ /** 选中时的按键 → 命令 id(如 `{ Enter: "yourname.gallery.edit" }`),命令收到该元素 */
47
+ keys?: Readonly<Record<string, string>>;
48
+ }
38
49
  export interface ElementSpec<E extends Element = Element> {
39
50
  /** 元素 type;内置类型沿用现有名字,兼容已有文档 */
40
51
  type: string;
@@ -53,6 +64,10 @@ export interface ElementSpec<E extends Element = Element> {
53
64
  drag?: ElementDrag;
54
65
  /** 内容注入声明(01 §5.4):drop 与 paste 共用的认领规则;块组件不再改写 insertData */
55
66
  ingest?: ElementIngestSpec;
67
+ /** 节点里的资源字段:导出打包、协作上传、灯箱等只按它找图片、视频、音频与文件 */
68
+ resources?: readonly ContentResourceSpec[];
69
+ /** void 块的公共外框;只适用于 `kind: "block"` 且 `isVoid` 的元素 */
70
+ frame?: boolean | ElementFrame;
56
71
  }
57
72
  export interface ExtensionLayerProps {
58
73
  /** 根 UI 在 Editable 外,不能通过 Slate 的 useReadOnly 读取此状态。 */
@@ -4,7 +4,7 @@
4
4
  * 只放写扩展需要的东西:声明、服务、主题、少量工具。编辑器组件、kit 工厂等宿主用的留在包根入口。
5
5
  * 未标注过渡用途的导出按稳定 API 维护;过渡导出只供现有内部消费者迁移使用。
6
6
  */
7
- export { EXTENSION_API_VERSION, defineExtension, defineElement, type EditorExtension, type ExtensionDefinition, type ElementSpec, type ElementEmptyContext, type ElementBehaviors, type ElementRenderProps, type ExtensionLayerProps, } from "./extension";
7
+ export { EXTENSION_API_VERSION, defineExtension, defineElement, type EditorExtension, type ExtensionDefinition, type ElementSpec, type ElementFrame, type ElementEmptyContext, type ElementBehaviors, type ElementRenderProps, type ExtensionLayerProps, } from "./extension";
8
8
  export type { AnyExtension } from "./legacy";
9
9
  export type { CopyDecision, CopyContext, ElementCopy } from "./copy";
10
10
  export type { MarkSpec, MarkRenderProps, DecorationSpec } from "./marks";
@@ -14,6 +14,10 @@ export { Tooltip as EditorTooltip } from "../ui/Tooltip";
14
14
  export { CaretRight as EditorCaretRight } from "../ui/CaretRight";
15
15
  export { getParagraphShortcutEntry } from "../utils/paragraphShortcut";
16
16
  export type { IngestContext, ElementIngestSpec } from "./ingest";
17
+ export { getElementResources } from "./resources";
18
+ export { ELEMENT_SELECTED_OUTLINE_CLASS, useElementFrame, type ElementFrameState, } from "../components/ElementFrame/index";
19
+ export { isOverlayTarget, isTextTarget, shouldIgnoreBlockKey } from "../utils/blockKeyTarget";
20
+ export type { ContentResourceSpec, ContentResourceKind, ContentResourceDeclarations, } from "../../../../db-schema/content-resources";
17
21
  export { CommandRegistry, createCommandDispatcher, type EditorCommand, type EditorCommandContext, type EditorHotkey, type RegisteredCommand, type CommandDispatcher, type CommandGuard, } from "./command";
18
22
  export { type EditorMenuEntry, type EditorMenuContext, type EditorMenuContribution, type MenuValue, } from "./menu";
19
23
  export { createServiceId, IThemeService, IExtensionErrorReporter, IEditorNotifier, type ServiceIdentifier, } from "../services/tokens";
@@ -0,0 +1,6 @@
1
+ import type { Editor } from "slate";
2
+ import type { ContentResourceDeclarations } from "../../../../db-schema/content-resources";
3
+ /** 内核在创建编辑器时绑定当前装配的资源声明 */
4
+ export declare function bindElementResources(editor: Editor, resources: ContentResourceDeclarations): void;
5
+ /** 当前编辑器已安装的节点资源声明(节点类型 → 资源字段) */
6
+ export declare function getElementResources(editor: Editor): ContentResourceDeclarations;
@@ -0,0 +1,25 @@
1
+ import { type ReactNode } from "react";
2
+ import { type RenderElementProps } from "slate-react";
3
+ import type { ElementFrame as ElementFrameOptions } from "../../api/extension";
4
+ /** 选中描边:外扩 3px,随内容圆角 */
5
+ export declare const ELEMENT_SELECTED_OUTLINE_CLASS = "outline-solid outline-2 outline-offset-[3px] outline-[var(--ed-accent)]";
6
+ export interface ElementFrameState {
7
+ selected: boolean;
8
+ select(): void;
9
+ deselect(): void;
10
+ /** 在指定位置打开块菜单(与右键、块手柄同一份条目) */
11
+ openMenu(position: {
12
+ x: number;
13
+ y: number;
14
+ }): void;
15
+ }
16
+ /** 外框里的块读取选中与菜单状态;不在外框里时为 null */
17
+ export declare const useElementFrame: () => ElementFrameState | null;
18
+ /**
19
+ * void 块的公共外框:点选与选中描边、右键菜单、选中时的删除与声明的按键、末尾「添加段落」。
20
+ * 块的 render 只渲染内容,attributes 与 children 由外框处理。
21
+ */
22
+ export declare function ElementFrame({ attributes, element, children, options, content, }: RenderElementProps & {
23
+ options: ElementFrameOptions;
24
+ content: ReactNode;
25
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,9 @@
1
+ /** 按键目标是文本输入(input / textarea / 可编辑区):块级快捷键不拦截 */
2
+ export declare const isTextTarget: (target: EventTarget | null) => boolean;
3
+ /** 按键目标在浮层里(灯箱、裁剪编辑器、对话框):交给浮层自己处理 */
4
+ export declare const isOverlayTarget: (target: EventTarget | null) => boolean;
5
+ /**
6
+ * 选中媒体块时的块级快捷键是否应忽略这次按键:输入法组合、浮层内、文本输入(如说明框)都不处理。
7
+ * 点选块后焦点通常留在外层编辑器根上——它包含这个块,不算文本输入,快捷键照常生效。
8
+ */
9
+ export declare function shouldIgnoreBlockKey(event: KeyboardEvent, block: HTMLElement | null): boolean;
@@ -4,6 +4,7 @@ import type { Disposable } from "./index";
4
4
  export { defineElement as defineEditorElement } from "../../editor/contracts";
5
5
  export type { ElementRenderProps as EditorElementRenderProps } from "../../editor/contracts";
6
6
  export type { MarkdownCodec as EditorMarkdownCodec } from "../../markdown/src/index";
7
+ export type { ContentResourceSpec as EditorResourceSpec, ContentResourceKind as EditorResourceKind, } from "../../db-schema/content-resources";
7
8
  /** 作者通过模块增强登记自定义节点,无需引用编辑器内部包。 */
8
9
  declare const elementMapBrand: unique symbol;
9
10
  declare const inlineMapBrand: unique symbol;
@@ -0,0 +1,11 @@
1
+ import type { ElementRuntime } from "../../editor/source-contracts";
2
+ export type { ElementAccess, BlockToolbarProps, BlockToolbarButtonProps, ElementRuntime, } from "../../editor/source-contracts";
3
+ export type { ElementFrame as EditorElementFrame } from "../../editor/contracts";
4
+ export declare function configureElement(value: ElementRuntime): void;
5
+ /** 读写当前节点:只读状态、外框选中、合并字段、删除、打开块菜单 */
6
+ export declare const useElement: ElementRuntime["useElement"];
7
+ /** 打开文档图片查看器,定位到该节点的第 index 个图片资源 */
8
+ export declare const useMediaViewer: ElementRuntime["useMediaViewer"];
9
+ export declare const BlockToolbar: ElementRuntime["BlockToolbar"];
10
+ export declare const BlockToolbarButton: ElementRuntime["BlockToolbarButton"];
11
+ export declare const BlockToolbarGap: ElementRuntime["BlockToolbarGap"];
@@ -12,6 +12,10 @@ export { SourceBlockEditor, useSourceBlock, useReportPreviewError, insertSourceB
12
12
  export type { SourceBlockEditorProps, PluginRuntimeStyleCompiler } from "./source-editor";
13
13
  export { useInlineSource, insertInlineSource, InlineSourceBoundary } from "./inline-source";
14
14
  export { ContentEditor, ContentPreview } from "./components";
15
+ export { useElement, useMediaViewer, BlockToolbar, BlockToolbarButton, BlockToolbarGap, } from "./element";
16
+ export type { ElementAccess, BlockToolbarProps, BlockToolbarButtonProps, EditorElementFrame, } from "./element";
17
+ export { MediaImage, MediaVideo, MediaAudio, useResource, useResourceUpload, useMediaAppearance, } from "./media";
18
+ export type { ResourceKind, ResourceState, ResourceUpload, MediaAppearance, MediaAppearancePreset, MediaImageProps, MediaVideoProps, MediaAudioProps, MediaRuntime, } from "./media";
15
19
  export type { ContentEditorProps, ContentPreviewProps } from "./components";
16
20
  export * from "./sidebar";
17
21
  export type * from "./icons";
@@ -0,0 +1,69 @@
1
+ import type { CSSProperties, ImgHTMLAttributes, MouseEventHandler, ReactElement, ReactNode, Ref, SyntheticEvent, VideoHTMLAttributes } from "react";
2
+ import type { ContentResourceKind } from "../../db-schema/content-resources";
3
+ export type ResourceKind = ContentResourceKind;
4
+ export interface ResourceState {
5
+ /** 可直接给 img / video / audio 的地址;解析中或失败时为 undefined */
6
+ src: string | undefined;
7
+ status: "loading" | "ready" | "error";
8
+ /** 元素加载失败时调用:作废宿主缓存后重新解析 */
9
+ retry(): void;
10
+ }
11
+ /** 上传到用户当前的资源存储(本地库、远端或共享文档),返回可写入节点的地址 */
12
+ export type ResourceUpload = (file: File) => Promise<string>;
13
+ export type MediaAppearancePreset = "none" | "sm" | "md" | "lg";
14
+ /** 用户设置(发布站为发布设置)里的媒体默认外观;节点自身的设置优先 */
15
+ export interface MediaAppearance {
16
+ borderRadius?: MediaAppearancePreset;
17
+ shadow?: MediaAppearancePreset | boolean;
18
+ }
19
+ export interface MediaImageProps {
20
+ url: string;
21
+ alt?: string;
22
+ className?: string;
23
+ style?: CSSProperties;
24
+ onClick?: MouseEventHandler<HTMLImageElement>;
25
+ onDoubleClick?: MouseEventHandler<HTMLImageElement>;
26
+ onLoad?: (event: SyntheticEvent<HTMLImageElement>) => void;
27
+ /** 宿主的回退地址也加载失败后回调 */
28
+ onLoadError?: () => void;
29
+ crossOrigin?: ImgHTMLAttributes<HTMLImageElement>["crossOrigin"];
30
+ /** 默认懒加载 */
31
+ loading?: "eager" | "lazy";
32
+ draggable?: boolean;
33
+ ref?: Ref<HTMLImageElement>;
34
+ }
35
+ export type MediaVideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
36
+ src: string;
37
+ playbackRate?: number;
38
+ onPlaybackRateChange?: (rate: number) => void;
39
+ /** 播放器底栏信息位,宿主播放器不支持时忽略 */
40
+ info?: ReactNode;
41
+ ref?: Ref<HTMLVideoElement>;
42
+ };
43
+ export interface MediaAudioProps {
44
+ src: string;
45
+ controls?: boolean;
46
+ label?: string;
47
+ labelSlot?: ReactNode;
48
+ className?: string;
49
+ ref?: Ref<HTMLAudioElement>;
50
+ }
51
+ /** 宿主注入的实现;在编辑器内按当前文档的作用域生效(共享文档走协作资源),编辑器外用应用默认实现。 */
52
+ export interface MediaRuntime {
53
+ useResource(url: string, options: {
54
+ kind: ResourceKind;
55
+ }): ResourceState;
56
+ /** 只读编辑器与没有资源存储的宿主(发布站)返回 undefined */
57
+ useResourceUpload(): ResourceUpload | undefined;
58
+ useMediaAppearance(): MediaAppearance | undefined;
59
+ MediaImage(props: MediaImageProps): ReactElement | null;
60
+ MediaVideo(props: MediaVideoProps): ReactElement | null;
61
+ MediaAudio(props: MediaAudioProps): ReactElement | null;
62
+ }
63
+ export declare function configureMedia(value: MediaRuntime): void;
64
+ export declare const useResource: MediaRuntime["useResource"];
65
+ export declare const useResourceUpload: MediaRuntime["useResourceUpload"];
66
+ export declare const useMediaAppearance: MediaRuntime["useMediaAppearance"];
67
+ export declare const MediaImage: (props: MediaImageProps) => ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | null;
68
+ export declare const MediaVideo: (props: MediaVideoProps) => ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | null;
69
+ export declare const MediaAudio: (props: MediaAudioProps) => ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | null;
@@ -44,6 +44,7 @@ export * from "./primitives/datepicker/DateRangePicker";
44
44
  export * from "./primitives/ImagePreview";
45
45
  export * from "./primitives/Tabs";
46
46
  export * from "./primitives/FileDropZone";
47
+ export * from "./primitives/Sortable";
47
48
  export * from "./primitives/LoadingOverlay";
48
49
  export { lockBodyScroll } from "./hooks/lockBodyScroll";
49
50
  export { mergeRefs } from "./primitives/mergeRefs";
@@ -0,0 +1,32 @@
1
+ import { type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
2
+ export interface SortableHandleProps {
3
+ onPointerDown(event: PointerEvent<HTMLElement>): void;
4
+ onKeyDown(event: KeyboardEvent<HTMLElement>): void;
5
+ tabIndex: number;
6
+ "aria-roledescription": string;
7
+ }
8
+ export interface SortableItemState {
9
+ index: number;
10
+ /** 正在被拖动的那一项 */
11
+ dragging: boolean;
12
+ /** 拖动经过、松手后会放到这里 */
13
+ over: boolean;
14
+ /** 展开到拖动手柄上;也可以展开到整项上,整项都能拖 */
15
+ handleProps: SortableHandleProps;
16
+ }
17
+ export interface SortableProps<T> {
18
+ items: readonly T[];
19
+ getKey(item: T): string;
20
+ /** 把 from 位置的项移到 to 位置 */
21
+ onMove(from: number, to: number): void;
22
+ renderItem(item: T, state: SortableItemState): ReactNode;
23
+ className?: string;
24
+ /** 每一项外层容器的类名 */
25
+ itemClassName?: string;
26
+ disabled?: boolean;
27
+ }
28
+ /**
29
+ * 列表与网格排序:按住手柄拖到另一项上松手即移到那里;手柄获得焦点时方向键前后移动一位,Esc 取消拖动。
30
+ * 用指针事件实现,不依赖拖放 API,任何容器(含编辑器里的块)都能用。
31
+ */
32
+ export declare function Sortable<T>({ items, getKey, onMove, renderItem, className, itemClassName, disabled, }: SortableProps<T>): import("react/jsx-runtime").JSX.Element;