@formicoidea/labre-core 0.26.0 → 0.28.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.
Files changed (23) hide show
  1. package/dist/_pkgs/affine-block-database/data-source.d.ts +28 -40
  2. package/dist/_pkgs/affine-block-database/data-source.js +116 -178
  3. package/dist/_pkgs/affine-block-database/database-block.d.ts +10 -3
  4. package/dist/_pkgs/affine-block-database/database-block.js +110 -70
  5. package/dist/_pkgs/affine-block-database/external-data-source.d.ts +69 -0
  6. package/dist/_pkgs/affine-block-database/external-data-source.js +144 -0
  7. package/dist/_pkgs/affine-block-database/index.d.ts +2 -0
  8. package/dist/_pkgs/affine-block-database/index.js +2 -0
  9. package/dist/_pkgs/affine-block-root/edgeless/configs/toolbar/misc.d.ts +18 -0
  10. package/dist/_pkgs/affine-block-root/edgeless/configs/toolbar/more.d.ts +18 -0
  11. package/dist/_pkgs/affine-block-root/edgeless/configs/toolbar/more.js +104 -2
  12. package/dist/_pkgs/affine-widget-edgeless-selected-rect/edgeless-element-link.d.ts +29 -0
  13. package/dist/_pkgs/affine-widget-edgeless-selected-rect/edgeless-element-link.js +233 -0
  14. package/dist/_pkgs/affine-widget-edgeless-selected-rect/effects.js +2 -0
  15. package/dist/_pkgs/affine-widget-edgeless-selected-rect/spec.d.ts +1 -0
  16. package/dist/_pkgs/affine-widget-edgeless-selected-rect/spec.js +2 -0
  17. package/dist/_pkgs/affine-widget-edgeless-selected-rect/view.js +2 -1
  18. package/dist/_pkgs/data-view/core/data-source/base.d.ts +6 -0
  19. package/dist/_pkgs/data-view/core/data-source/base.js +6 -0
  20. package/dist/_pkgs/std/gfx/model/surface/element-model.d.ts +7 -0
  21. package/dist/_pkgs/std/gfx/model/surface/element-model.js +24 -1
  22. package/dist/_pkgs/std/inline/components/v-element.js +5 -2
  23. package/package.json +1 -1
@@ -6,7 +6,7 @@ import { toast } from '../affine-components/toast/index.js';
6
6
  import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '../affine-shared/consts/index.js';
7
7
  import { BlockCommentManager, CommentProviderIdentifier, DocModeProvider, NotificationProvider, TelemetryProvider, } from '../affine-shared/services/index.js';
8
8
  import { getDropResult } from '../affine-widget-drag-handle/index.js';
9
- import { createRecordDetail, createUniComponentFromWebComponent, DataViewRootUILogic, defineUniComponent, ExternalGroupByConfigProvider, lazy, renderUniLit, uniMap, } from '../data-view/index.js';
9
+ import { createRecordDetail, createUniComponentFromWebComponent, DataViewRootUILogic, defineUniComponent, ExternalGroupByConfigProvider, renderUniLit, uniMap, } from '../data-view/index.js';
10
10
  import { widgetPresets } from '../data-view/widget-presets/index.js';
11
11
  import { Rect } from '../global/gfx/index.js';
12
12
  import { CommentIcon, CopyIcon, DeleteIcon, MoreHorizontalIcon, } from '@blocksuite/icons/lit';
@@ -16,6 +16,7 @@ import { Slice } from '../store/index.js';
16
16
  import { autoUpdate } from '@floating-ui/dom';
17
17
  import { computed, signal } from '@preact/signals-core';
18
18
  import { html, nothing } from 'lit';
19
+ import { keyed } from 'lit/directives/keyed.js';
19
20
  import { popSideDetail } from './components/layout.js';
20
21
  import { DatabaseConfigExtension } from './config.js';
21
22
  import { EditorHostKey } from './context/host-context.js';
@@ -88,25 +89,10 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent {
88
89
  options,
89
90
  });
90
91
  };
91
- this.dataSource = lazy(() => {
92
- const initFn = (dataSource) => {
93
- dataSource.serviceSet(EditorHostKey, this.host);
94
- this.std.provider
95
- .getAll(ExternalGroupByConfigProvider)
96
- .forEach(config => {
97
- dataSource.serviceSet(ExternalGroupByConfigProvider(config.name), config);
98
- });
99
- };
100
- const provider = this.std.getOptional(DatabaseDataSourceProvider);
101
- const dataSource = provider && this.model.props.externalSourceId
102
- ? provider.createDataSource(this.model, initFn)
103
- : new DatabaseBlockDataSource(this.model, initFn);
104
- const id = currentViewStorage.getCurrentView(this.model.id);
105
- if (id && dataSource.viewManager.viewGet(id)) {
106
- dataSource.viewManager.setCurrentView(id);
107
- }
108
- return dataSource;
109
- });
92
+ // Bumps on every rebuild so `renderBlock` can `keyed()` the renderer element,
93
+ // forcing Lit to tear the old one down (disposing its listeners) and mount a
94
+ // fresh one bound to the new source.
95
+ this._sourceVersion = 0;
110
96
  this.renderTitle = (dataViewLogic) => {
111
97
  return html ` <affine-database-title
112
98
  class="${databaseTitleStyles}"
@@ -219,7 +205,107 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent {
219
205
  return databaseSelection?.viewSelection;
220
206
  });
221
207
  this.virtualPadding$ = signal(0);
222
- this.dataViewRootLogic = lazy(() => new DataViewRootUILogic({
208
+ this.#useZeroWidth_accessor_storage = true;
209
+ }
210
+ _buildDataSource() {
211
+ const initFn = (dataSource) => {
212
+ dataSource.serviceSet(EditorHostKey, this.host);
213
+ this.std.provider
214
+ .getAll(ExternalGroupByConfigProvider)
215
+ .forEach(config => {
216
+ dataSource.serviceSet(ExternalGroupByConfigProvider(config.name), config);
217
+ });
218
+ };
219
+ const provider = this.std.getOptional(DatabaseDataSourceProvider);
220
+ const dataSource = provider && this.model.props.externalSourceId
221
+ ? provider.createDataSource(this.model, initFn)
222
+ : new DatabaseBlockDataSource(this.model, initFn);
223
+ const id = currentViewStorage.getCurrentView(this.model.id);
224
+ if (id && dataSource.viewManager.viewGet(id)) {
225
+ dataSource.viewManager.setCurrentView(id);
226
+ }
227
+ return dataSource;
228
+ }
229
+ get dataSource() {
230
+ if (!this._dataSource) {
231
+ this._dataSource = this._buildDataSource();
232
+ }
233
+ return this._dataSource;
234
+ }
235
+ rebuildDataSource() {
236
+ this._dataSource?.dispose();
237
+ this._dataSource = undefined;
238
+ this._dataViewRootLogic = undefined;
239
+ this._sourceVersion++;
240
+ this.requestUpdate();
241
+ }
242
+ get optionsConfig() {
243
+ return {
244
+ configure: (_model, options) => options,
245
+ ...this.std.getOptional(DatabaseConfigExtension.identifier),
246
+ };
247
+ }
248
+ get isCommentHighlighted() {
249
+ return (this.std
250
+ .getOptional(BlockCommentManager)
251
+ ?.isBlockCommentHighlighted(this.model) ?? false);
252
+ }
253
+ get topContenteditableElement() {
254
+ if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
255
+ return this.closest(EDGELESS_TOP_CONTENTEDITABLE_SELECTOR);
256
+ }
257
+ return this.rootComponent;
258
+ }
259
+ renderDatabaseOps() {
260
+ if (this.dataSource.readonly$.value) {
261
+ return nothing;
262
+ }
263
+ return html ` <div
264
+ data-testid="database-ops"
265
+ class="${databaseOpsStyles}"
266
+ @click="${this.clickDatabaseOps}"
267
+ >
268
+ ${MoreHorizontalIcon()}
269
+ </div>`;
270
+ }
271
+ connectedCallback() {
272
+ super.connectedCallback();
273
+ this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
274
+ this.classList.add(databaseBlockStyles);
275
+ this.listenFullWidthChange();
276
+ // Rebuild the source whenever the block is re-pointed at (or detached from)
277
+ // an external collection. The first (synchronous) emission is skipped: the
278
+ // source is still lazy at this point, so there is nothing to rebuild.
279
+ this.disposables.add(this.model.props.externalSourceId$.subscribe(() => {
280
+ if (this._dataSource) {
281
+ this.rebuildDataSource();
282
+ }
283
+ }));
284
+ }
285
+ disconnectedCallback() {
286
+ this._dataSource?.dispose();
287
+ this._dataSource = undefined;
288
+ this._dataViewRootLogic = undefined;
289
+ super.disconnectedCallback();
290
+ }
291
+ listenFullWidthChange() {
292
+ if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
293
+ return;
294
+ }
295
+ this.disposables.add(autoUpdate(this.host, this, () => {
296
+ const padding = this.getBoundingClientRect().left -
297
+ this.host.getBoundingClientRect().left;
298
+ this.virtualPadding$.value = Math.max(0, padding - 72);
299
+ }));
300
+ }
301
+ get dataViewRootLogic() {
302
+ if (!this._dataViewRootLogic) {
303
+ this._dataViewRootLogic = this._buildDataViewRootLogic();
304
+ }
305
+ return this._dataViewRootLogic;
306
+ }
307
+ _buildDataViewRootLogic() {
308
+ return new DataViewRootUILogic({
223
309
  virtualPadding$: this.virtualPadding$,
224
310
  bindHotkey: hotkeys => {
225
311
  return {
@@ -237,7 +323,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent {
237
323
  },
238
324
  selection$: this.viewSelection$,
239
325
  setSelection: this.setSelection,
240
- dataSource: this.dataSource.value,
326
+ dataSource: this.dataSource,
241
327
  headerWidget: this.headerWidget,
242
328
  onDrag: this.onDrag,
243
329
  clipboard: this.std.clipboard,
@@ -297,58 +383,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent {
297
383
  }
298
384
  },
299
385
  },
300
- }));
301
- this.#useZeroWidth_accessor_storage = true;
302
- }
303
- get optionsConfig() {
304
- return {
305
- configure: (_model, options) => options,
306
- ...this.std.getOptional(DatabaseConfigExtension.identifier),
307
- };
308
- }
309
- get isCommentHighlighted() {
310
- return (this.std
311
- .getOptional(BlockCommentManager)
312
- ?.isBlockCommentHighlighted(this.model) ?? false);
313
- }
314
- get topContenteditableElement() {
315
- if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
316
- return this.closest(EDGELESS_TOP_CONTENTEDITABLE_SELECTOR);
317
- }
318
- return this.rootComponent;
319
- }
320
- renderDatabaseOps() {
321
- if (this.dataSource.value.readonly$.value) {
322
- return nothing;
323
- }
324
- return html ` <div
325
- data-testid="database-ops"
326
- class="${databaseOpsStyles}"
327
- @click="${this.clickDatabaseOps}"
328
- >
329
- ${MoreHorizontalIcon()}
330
- </div>`;
331
- }
332
- connectedCallback() {
333
- super.connectedCallback();
334
- this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
335
- this.classList.add(databaseBlockStyles);
336
- this.listenFullWidthChange();
337
- }
338
- listenFullWidthChange() {
339
- if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
340
- return;
341
- }
342
- this.disposables.add(autoUpdate(this.host, this, () => {
343
- const padding = this.getBoundingClientRect().left -
344
- this.host.getBoundingClientRect().left;
345
- this.virtualPadding$.value = Math.max(0, padding - 72);
346
- }));
386
+ });
347
387
  }
348
388
  renderBlock() {
349
389
  return html `
350
390
  <div contenteditable="false" class="${databaseContentStyles}">
351
- ${this.dataViewRootLogic.value.render()}
391
+ ${keyed(this._sourceVersion, this.dataViewRootLogic.render())}
352
392
  </div>
353
393
  `;
354
394
  }
@@ -0,0 +1,69 @@
1
+ import type { DatabaseBlockModel } from '../affine-model/index.js';
2
+ import type { InsertToPosition } from '../affine-shared/utils/index.js';
3
+ import { type DatabaseFlags, DataSourceBase, type DataViewDataType, type PropertyMetaConfig, type TypeInstance, type ViewManager, type ViewMeta } from '../data-view/index.js';
4
+ import { DisposableGroup } from '../global/disposable/index.js';
5
+ import { type ReadonlySignal } from '@preact/signals-core';
6
+ /**
7
+ * Intermediate base for `affine:database` data sources whose **view config**
8
+ * keeps living in the block model blob (`model.props.views`) while the **data
9
+ * layer** (rows / cells / columns) is redirected elsewhere — the inline blob
10
+ * source, or a host-provided external collection.
11
+ *
12
+ * It implements everything generic — the view manager / metas / converts, the
13
+ * view-data CRUD, feature flags, readonly, and the reactive plumbing — so a
14
+ * concrete source only provides a small synchronous data contract (`getRows`,
15
+ * `getCellValue`, …) plus the mutations, and calls {@link invalidate} from its
16
+ * own change observers.
17
+ *
18
+ * Reactivity: the data-layer reads ({@link rows$}, {@link properties$} and the
19
+ * `*Get` getters that `DataSourceBase` wraps in `computed()`) all depend on
20
+ * {@link revision}. A source backed by reactive blocksuite signals (the blob
21
+ * source) tracks those naturally and never bumps `revision` — cost is nil. A
22
+ * source reading raw `Y.Map`s bumps `revision` via {@link invalidate} so the
23
+ * computeds re-run. Defining `rows$`/`properties$` here (not as instance fields
24
+ * reassigned in a subclass constructor) also avoids the "reassign after super"
25
+ * dance.
26
+ */
27
+ export declare abstract class ExternalDataSourceBase extends DataSourceBase {
28
+ protected readonly _model: DatabaseBlockModel;
29
+ /** Bumped by {@link invalidate} to re-run the data-layer computeds. */
30
+ protected readonly revision: import("@preact/signals-core").Signal<number>;
31
+ protected readonly disposables: DisposableGroup;
32
+ constructor(model: DatabaseBlockModel);
33
+ /** Notify the view layer that the underlying data changed. */
34
+ protected invalidate(): void;
35
+ dispose(): void;
36
+ get doc(): import("../store/index.js").Store;
37
+ get parentProvider(): import("../global/di/provider.js").ServiceProvider;
38
+ protected abstract getRows(): string[];
39
+ protected abstract getProperties(): string[];
40
+ protected abstract getCellValue(rowId: string, propertyId: string): unknown;
41
+ protected abstract getPropertyType(propertyId: string): string | undefined;
42
+ protected abstract getPropertyName(propertyId: string): string;
43
+ protected abstract getPropertyData(propertyId: string): Record<string, unknown>;
44
+ protected abstract getPropertyDataType(propertyId: string): TypeInstance | undefined;
45
+ protected abstract getPropertyMetas(): PropertyMetaConfig[];
46
+ rows$: ReadonlySignal<string[]>;
47
+ properties$: ReadonlySignal<string[]>;
48
+ allPropertyMetas$: ReadonlySignal<PropertyMetaConfig[]>;
49
+ propertyMetas$: ReadonlySignal<PropertyMetaConfig[]>;
50
+ cellValueGet(rowId: string, propertyId: string): unknown;
51
+ propertyTypeGet(propertyId: string): string | undefined;
52
+ propertyNameGet(propertyId: string): string;
53
+ propertyDataGet(propertyId: string): Record<string, unknown>;
54
+ propertyDataTypeGet(propertyId: string): TypeInstance | undefined;
55
+ featureFlags$: ReadonlySignal<DatabaseFlags>;
56
+ readonly$: ReadonlySignal<boolean>;
57
+ viewConverts: import("./index.js").ViewConvertConfig[];
58
+ viewMetas: ViewMeta[];
59
+ viewManager: ViewManager;
60
+ viewDataList$: ReadonlySignal<DataViewDataType[]>;
61
+ viewDataGet(viewId: string): DataViewDataType | undefined;
62
+ viewDataAdd(viewData: DataViewDataType): string;
63
+ viewDataDelete(viewId: string): void;
64
+ viewDataDuplicate(id: string): string;
65
+ viewDataMoveTo(id: string, position: InsertToPosition): void;
66
+ viewDataUpdate<ViewData extends DataViewDataType>(id: string, updater: (data: ViewData) => Partial<ViewData>): void;
67
+ viewMetaGet(type: string): ViewMeta;
68
+ viewMetaGetById(viewId: string): ViewMeta | undefined;
69
+ }
@@ -0,0 +1,144 @@
1
+ import { FeatureFlagService } from '../affine-shared/services/index.js';
2
+ import { DataSourceBase, ViewManagerBase, } from '../data-view/index.js';
3
+ import { DisposableGroup } from '../global/disposable/index.js';
4
+ import { IS_MOBILE } from '../global/env/index.js';
5
+ import { BlockSuiteError, ErrorCode } from '../global/exceptions/index.js';
6
+ import { computed, signal, } from '@preact/signals-core';
7
+ import { deleteView, duplicateView, moveViewTo, updateView, } from './utils/block-utils.js';
8
+ import { databaseBlockViewConverts, databaseBlockViewMap, databaseBlockViews, } from './views/index.js';
9
+ /**
10
+ * Intermediate base for `affine:database` data sources whose **view config**
11
+ * keeps living in the block model blob (`model.props.views`) while the **data
12
+ * layer** (rows / cells / columns) is redirected elsewhere — the inline blob
13
+ * source, or a host-provided external collection.
14
+ *
15
+ * It implements everything generic — the view manager / metas / converts, the
16
+ * view-data CRUD, feature flags, readonly, and the reactive plumbing — so a
17
+ * concrete source only provides a small synchronous data contract (`getRows`,
18
+ * `getCellValue`, …) plus the mutations, and calls {@link invalidate} from its
19
+ * own change observers.
20
+ *
21
+ * Reactivity: the data-layer reads ({@link rows$}, {@link properties$} and the
22
+ * `*Get` getters that `DataSourceBase` wraps in `computed()`) all depend on
23
+ * {@link revision}. A source backed by reactive blocksuite signals (the blob
24
+ * source) tracks those naturally and never bumps `revision` — cost is nil. A
25
+ * source reading raw `Y.Map`s bumps `revision` via {@link invalidate} so the
26
+ * computeds re-run. Defining `rows$`/`properties$` here (not as instance fields
27
+ * reassigned in a subclass constructor) also avoids the "reassign after super"
28
+ * dance.
29
+ */
30
+ export class ExternalDataSourceBase extends DataSourceBase {
31
+ constructor(model) {
32
+ super();
33
+ /** Bumped by {@link invalidate} to re-run the data-layer computeds. */
34
+ this.revision = signal(0);
35
+ this.disposables = new DisposableGroup();
36
+ // --- reactive bridge over the data contract -----------------------------
37
+ this.rows$ = computed(() => {
38
+ void this.revision.value;
39
+ return this.getRows();
40
+ });
41
+ this.properties$ = computed(() => {
42
+ void this.revision.value;
43
+ return this.getProperties();
44
+ });
45
+ this.allPropertyMetas$ = computed(() => {
46
+ void this.revision.value;
47
+ return this.getPropertyMetas();
48
+ });
49
+ this.propertyMetas$ = computed(() => {
50
+ return this.allPropertyMetas$.value.filter(v => !v.config.fixed && !v.config.hide);
51
+ });
52
+ // --- view surface (config stays in the model blob) ----------------------
53
+ this.featureFlags$ = computed(() => {
54
+ const featureFlagService = this.doc.get(FeatureFlagService);
55
+ return {
56
+ enable_number_formatting: featureFlagService.getFlag('enable_database_number_formatting') ??
57
+ false,
58
+ enable_table_virtual_scroll: featureFlagService.getFlag('enable_table_virtual_scroll') ?? false,
59
+ };
60
+ });
61
+ this.readonly$ = computed(() => {
62
+ return (this._model.store.readonly ||
63
+ // TODO(@L-Sun): use block level readonly
64
+ IS_MOBILE);
65
+ });
66
+ this.viewConverts = databaseBlockViewConverts;
67
+ this.viewMetas = databaseBlockViews;
68
+ this.viewManager = new ViewManagerBase(this);
69
+ this.viewDataList$ = computed(() => {
70
+ return this._model.props.views$.value;
71
+ });
72
+ this._model = model;
73
+ }
74
+ /** Notify the view layer that the underlying data changed. */
75
+ invalidate() {
76
+ this.revision.value++;
77
+ }
78
+ dispose() {
79
+ this.disposables.dispose();
80
+ }
81
+ get doc() {
82
+ return this._model.store;
83
+ }
84
+ get parentProvider() {
85
+ return this._model.store.provider;
86
+ }
87
+ cellValueGet(rowId, propertyId) {
88
+ void this.revision.value;
89
+ return this.getCellValue(rowId, propertyId);
90
+ }
91
+ propertyTypeGet(propertyId) {
92
+ void this.revision.value;
93
+ return this.getPropertyType(propertyId);
94
+ }
95
+ propertyNameGet(propertyId) {
96
+ void this.revision.value;
97
+ return this.getPropertyName(propertyId);
98
+ }
99
+ propertyDataGet(propertyId) {
100
+ void this.revision.value;
101
+ return this.getPropertyData(propertyId);
102
+ }
103
+ propertyDataTypeGet(propertyId) {
104
+ void this.revision.value;
105
+ return this.getPropertyDataType(propertyId);
106
+ }
107
+ viewDataGet(viewId) {
108
+ return this.viewDataList$.value.find(data => data.id === viewId);
109
+ }
110
+ viewDataAdd(viewData) {
111
+ this._model.store.captureSync();
112
+ this._model.store.transact(() => {
113
+ this._model.props.views = [...this._model.props.views, viewData];
114
+ });
115
+ return viewData.id;
116
+ }
117
+ viewDataDelete(viewId) {
118
+ this._model.store.captureSync();
119
+ deleteView(this._model, viewId);
120
+ }
121
+ viewDataDuplicate(id) {
122
+ return duplicateView(this._model, id);
123
+ }
124
+ viewDataMoveTo(id, position) {
125
+ moveViewTo(this._model, id, position);
126
+ }
127
+ viewDataUpdate(id, updater) {
128
+ updateView(this._model, id, updater);
129
+ }
130
+ viewMetaGet(type) {
131
+ const view = databaseBlockViewMap[type];
132
+ if (!view) {
133
+ throw new BlockSuiteError(ErrorCode.DatabaseBlockError, `Unknown view type: ${type}`);
134
+ }
135
+ return view;
136
+ }
137
+ viewMetaGetById(viewId) {
138
+ const view = this.viewDataGet(viewId);
139
+ if (!view) {
140
+ return;
141
+ }
142
+ return this.viewMetaGet(view.mode);
143
+ }
144
+ }
@@ -4,6 +4,7 @@ export * from './config';
4
4
  export * from './context';
5
5
  export * from './data-source';
6
6
  export * from './database-block';
7
+ export * from './external-data-source';
7
8
  export * from './detail-panel/block-renderer';
8
9
  export * from './detail-panel/note-renderer';
9
10
  export * from './properties';
@@ -11,4 +12,5 @@ export * from './properties/rich-text/cell-renderer';
11
12
  export * from './selection.js';
12
13
  export * from './service';
13
14
  export * from './utils/block-utils';
15
+ export * from './views';
14
16
  export * from '../data-view/index.js';
@@ -4,6 +4,7 @@ export * from './config';
4
4
  export * from './context';
5
5
  export * from './data-source';
6
6
  export * from './database-block';
7
+ export * from './external-data-source';
7
8
  export * from './detail-panel/block-renderer';
8
9
  export * from './detail-panel/note-renderer';
9
10
  export * from './properties';
@@ -11,4 +12,5 @@ export * from './properties/rich-text/cell-renderer';
11
12
  export * from './selection.js';
12
13
  export * from './service';
13
14
  export * from './utils/block-utils';
15
+ export * from './views';
14
16
  export * from '../data-view/index.js';
@@ -118,6 +118,24 @@ export declare const builtinMiscToolbarConfig: {
118
118
  readonly icon: import("lit-html").TemplateResult<1>;
119
119
  readonly when: (ctx: ToolbarContext) => boolean;
120
120
  readonly run: (ctx: ToolbarContext) => void;
121
+ }, {
122
+ readonly id: "c.link-element";
123
+ readonly label: "Link";
124
+ readonly icon: import("lit-html").TemplateResult<1>;
125
+ readonly when: (ctx: ToolbarContext) => boolean;
126
+ readonly run: (ctx: ToolbarContext) => void;
127
+ }, {
128
+ readonly id: "c.edit-element-link";
129
+ readonly label: "Edit link";
130
+ readonly icon: import("lit-html").TemplateResult<1>;
131
+ readonly when: (ctx: ToolbarContext) => boolean;
132
+ readonly run: (ctx: ToolbarContext) => void;
133
+ }, {
134
+ readonly id: "c.remove-element-link";
135
+ readonly label: "Remove link";
136
+ readonly icon: import("lit-html").TemplateResult<1>;
137
+ readonly when: (ctx: ToolbarContext) => boolean;
138
+ readonly run: (ctx: ToolbarContext) => void;
121
139
  }];
122
140
  } | {
123
141
  placement: ActionPlacement;
@@ -69,6 +69,24 @@ export declare const moreActions: [{
69
69
  readonly icon: import("lit-html").TemplateResult<1>;
70
70
  readonly when: (ctx: ToolbarContext) => boolean;
71
71
  readonly run: (ctx: ToolbarContext) => void;
72
+ }, {
73
+ readonly id: "c.link-element";
74
+ readonly label: "Link";
75
+ readonly icon: import("lit-html").TemplateResult<1>;
76
+ readonly when: (ctx: ToolbarContext) => boolean;
77
+ readonly run: (ctx: ToolbarContext) => void;
78
+ }, {
79
+ readonly id: "c.edit-element-link";
80
+ readonly label: "Edit link";
81
+ readonly icon: import("lit-html").TemplateResult<1>;
82
+ readonly when: (ctx: ToolbarContext) => boolean;
83
+ readonly run: (ctx: ToolbarContext) => void;
84
+ }, {
85
+ readonly id: "c.remove-element-link";
86
+ readonly label: "Remove link";
87
+ readonly icon: import("lit-html").TemplateResult<1>;
88
+ readonly when: (ctx: ToolbarContext) => boolean;
89
+ readonly run: (ctx: ToolbarContext) => void;
72
90
  }];
73
91
  }, {
74
92
  readonly id: "e.delete";
@@ -6,10 +6,11 @@ import { ImageBlockComponent } from '../../../../affine-block-image/index.js';
6
6
  import { EdgelessCRUDIdentifier, getSurfaceComponent, } from '../../../../affine-block-surface/index.js';
7
7
  import { createGroupFromSelectedCommand } from '../../../../affine-gfx-group/index.js';
8
8
  import { AttachmentBlockModel, BookmarkBlockModel, EmbedLinkedDocBlockSchema, EmbedLinkedDocModel, EmbedSyncedDocBlockSchema, EmbedSyncedDocModel, FrameBlockModel, ImageBlockModel, isExternalEmbedModel, NoteBlockModel, ParagraphBlockModel, } from '../../../../affine-model/index.js';
9
+ import { QuickSearchProvider } from '../../../../affine-shared/services/index.js';
9
10
  import { matchModels, } from '../../../../affine-shared/utils/index.js';
10
11
  import { Bound, getCommonBoundWithRotation } from '../../../../global/gfx/index.js';
11
- import { ArrowDownBigBottomIcon, ArrowDownBigIcon, ArrowUpBigIcon, ArrowUpBigTopIcon, CopyIcon, DeleteIcon, DuplicateIcon, FrameIcon, GroupIcon, LinkedPageIcon, ResetIcon, } from '@blocksuite/icons/lit';
12
- import { GfxBlockElementModel } from '../../../../std/gfx/index.js';
12
+ import { ArrowDownBigBottomIcon, ArrowDownBigIcon, ArrowUpBigIcon, ArrowUpBigTopIcon, CopyIcon, DeleteIcon, DuplicateIcon, FrameIcon, GroupIcon, LinkIcon, LinkedPageIcon, ResetIcon, UnlinkIcon, } from '@blocksuite/icons/lit';
13
+ import { GfxBlockElementModel, GfxPrimitiveElementModel, } from '../../../../std/gfx/index.js';
13
14
  import { EdgelessClipboardController } from '../../clipboard/clipboard';
14
15
  import { duplicate } from '../../utils/clipboard-utils';
15
16
  import { getSortedCloneElements } from '../../utils/clone-utils';
@@ -298,6 +299,54 @@ export const moreActions = [
298
299
  create().catch(console.error);
299
300
  },
300
301
  },
302
+ {
303
+ id: 'c.link-element',
304
+ // Attach a link (existing doc or external URL) to a single drawing
305
+ // element. A hover arrow (edgeless-element-link widget) opens it.
306
+ label: 'Link',
307
+ icon: LinkIcon(),
308
+ when(ctx) {
309
+ const el = getLinkableElement(ctx);
310
+ return el !== null && !hasElementLink(el) && canPickLink(ctx);
311
+ },
312
+ run(ctx) {
313
+ const el = getLinkableElement(ctx);
314
+ if (el)
315
+ linkElementViaQuickSearch(ctx, el);
316
+ },
317
+ },
318
+ {
319
+ id: 'c.edit-element-link',
320
+ label: 'Edit link',
321
+ icon: LinkIcon(),
322
+ when(ctx) {
323
+ const el = getLinkableElement(ctx);
324
+ return el !== null && hasElementLink(el) && canPickLink(ctx);
325
+ },
326
+ run(ctx) {
327
+ const el = getLinkableElement(ctx);
328
+ if (el)
329
+ linkElementViaQuickSearch(ctx, el);
330
+ },
331
+ },
332
+ {
333
+ id: 'c.remove-element-link',
334
+ label: 'Remove link',
335
+ icon: UnlinkIcon(),
336
+ when(ctx) {
337
+ const el = getLinkableElement(ctx);
338
+ return el !== null && hasElementLink(el);
339
+ },
340
+ run(ctx) {
341
+ const el = getLinkableElement(ctx);
342
+ if (!el)
343
+ return;
344
+ ctx.std.get(EdgelessCRUDIdentifier).updateElement(el.id, {
345
+ linkedDocId: undefined,
346
+ externalLink: undefined,
347
+ });
348
+ },
349
+ },
301
350
  ],
302
351
  },
303
352
  // Deleting Group
@@ -321,6 +370,59 @@ export const moreActions = [
321
370
  },
322
371
  },
323
372
  ];
373
+ /** The single selected drawing element a link can be attached to, or null. */
374
+ function getLinkableElement(ctx) {
375
+ const models = ctx.getSurfaceModels();
376
+ return models.length === 1 && models[0] instanceof GfxPrimitiveElementModel
377
+ ? models[0]
378
+ : null;
379
+ }
380
+ function hasElementLink(el) {
381
+ return Boolean(el.linkedDocId || el.externalLink);
382
+ }
383
+ /**
384
+ * Picking a doc/URL needs the host-provided quick-search modal. Hide the
385
+ * Link / Edit link items where it is absent (they would no-op); Remove link
386
+ * stays available since clearing needs no picker.
387
+ */
388
+ function canPickLink(ctx) {
389
+ return Boolean(ctx.std.getOptional(QuickSearchProvider));
390
+ }
391
+ /** Pick a doc or URL via the quick-search modal and store it on the element. */
392
+ function linkElementViaQuickSearch(ctx, el) {
393
+ const quickSearch = ctx.std.getOptional(QuickSearchProvider);
394
+ if (!quickSearch)
395
+ return;
396
+ quickSearch
397
+ .openQuickSearch()
398
+ .then(result => {
399
+ if (!result)
400
+ return;
401
+ const crud = ctx.std.get(EdgelessCRUDIdentifier);
402
+ if ('docId' in result) {
403
+ crud.updateElement(el.id, {
404
+ linkedDocId: result.docId,
405
+ externalLink: undefined,
406
+ });
407
+ ctx.track('LinkedDocCreated', {
408
+ control: 'link element',
409
+ type: 'element-ref',
410
+ other: 'existing doc',
411
+ });
412
+ }
413
+ else if ('externalUrl' in result) {
414
+ crud.updateElement(el.id, {
415
+ externalLink: result.externalUrl,
416
+ linkedDocId: undefined,
417
+ });
418
+ ctx.track('Link', {
419
+ control: 'link element',
420
+ type: 'external url',
421
+ });
422
+ }
423
+ })
424
+ .catch(console.error);
425
+ }
324
426
  function reorderElements(ctx, models, type) {
325
427
  if (!models.length)
326
428
  return;
@@ -0,0 +1,29 @@
1
+ import type { RootBlockModel } from '../affine-model/index.js';
2
+ import { WidgetComponent } from '../std/index.js';
3
+ import { nothing } from 'lit';
4
+ export declare const EDGELESS_ELEMENT_LINK_WIDGET = "edgeless-element-link";
5
+ /**
6
+ * Shows a clickable arrow when hovering a drawing element that carries a link
7
+ * ({@link GfxPrimitiveElementModel.linkedDocId} or `.externalLink`). Clicking it
8
+ * opens the linked doc (host side-view via `docLinkClicked`) or the URL.
9
+ *
10
+ * ponytail: hover is resolved via `getElementByPoint`, which returns the topmost
11
+ * leaf — a link set on a group only shows when a child is hovered. Good enough
12
+ * for v1 (shapes/text/connectors are hit directly).
13
+ */
14
+ export declare class EdgelessElementLinkWidget extends WidgetComponent<RootBlockModel> {
15
+ static styles: import("lit").CSSResult;
16
+ private _hideTimer;
17
+ private accessor _target;
18
+ private accessor _rect;
19
+ get gfx(): import("../std/gfx/controller.js").GfxController;
20
+ private readonly _updateRect;
21
+ private _hasLink;
22
+ private _setTarget;
23
+ /** The hovered element if it carries a link, else its nearest linked group. */
24
+ private _resolveLinked;
25
+ private readonly _open;
26
+ private readonly _onKeydown;
27
+ firstUpdated(): void;
28
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
29
+ }