From 7f45993fdbab1e0d2bc5944316462f8e45cbaae7 Mon Sep 17 00:00:00 2001 From: Saul-Mirone Date: Thu, 13 Mar 2025 07:06:26 +0000 Subject: [PATCH] feat(editor): add ui for display block meta in toolbar (#10817) --- blocksuite/framework/global/src/lit/index.ts | 1 + blocksuite/framework/global/src/lit/watch.ts | 68 ++++++++++++++++ .../extensions/editor-config/toolbar/index.ts | 77 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 blocksuite/framework/global/src/lit/watch.ts diff --git a/blocksuite/framework/global/src/lit/index.ts b/blocksuite/framework/global/src/lit/index.ts index 9fc9315e17..5c5ef4d618 100644 --- a/blocksuite/framework/global/src/lit/index.ts +++ b/blocksuite/framework/global/src/lit/index.ts @@ -1,2 +1,3 @@ export * from './signal-watcher.js'; +export * from './watch.js'; export * from './with-disposable.js'; diff --git a/blocksuite/framework/global/src/lit/watch.ts b/blocksuite/framework/global/src/lit/watch.ts new file mode 100644 index 0000000000..822658f243 --- /dev/null +++ b/blocksuite/framework/global/src/lit/watch.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { type Signal } from '@preact/signals-core'; +import { AsyncDirective } from 'lit/async-directive.js'; +import { directive } from 'lit/directive.js'; + +class WatchDirective extends AsyncDirective { + private __signal?: Signal; + private __dispose?: () => void; + + override render(signal: Signal) { + if (signal !== this.__signal) { + this.__dispose?.(); + this.__signal = signal; + + // Whether the subscribe() callback is called because of this render + // pass, or because of a separate signal update. + let updateFromLit = true; + this.__dispose = signal.subscribe(value => { + // The subscribe() callback is called synchronously during subscribe. + // Ignore the first call since we return the value below in that case. + if (updateFromLit === false) { + this.setValue(value); + } + }); + updateFromLit = false; + } + + // We use peek() so that the signal access is not tracked by the effect + // created by SignalWatcher.performUpdate(). This means that a signal + // update won't trigger a full element update if it's only passed to + // watch() and not otherwise accessed by the element. + return signal.peek(); + } + + protected override disconnected(): void { + this.__dispose?.(); + } + + protected override reconnected(): void { + // Since we disposed the subscription in disconnected() we need to + // resubscribe here. We don't ignore the synchronous callback call because + // the signal might have changed while the directive is disconnected. + // + // There are two possible reasons for a disconnect: + // 1. The host element was disconnected. + // 2. The directive was not rendered during a render + // In the first case the element will not schedule an update on reconnect, + // so we need the synchronous call here to set the current value. + // In the second case, we're probably reconnecting *because* of a render, + // so the synchronous call here will go before a render call, and we'll get + // two sets of the value (setValue() here and the return in render()), but + // this is ok because the value will be dirty-checked by lit-html. + this.__dispose = this.__signal?.subscribe(value => { + this.setValue(value); + }); + } +} + +/** + * Renders a signal and subscribes to it, updating the part when the signal + * changes. + */ +export const watch = directive(WatchDirective); diff --git a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts index 8546147f44..2755ae762d 100644 --- a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts +++ b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts @@ -39,6 +39,7 @@ import type { MenuContext, MenuItemGroup, } from '@blocksuite/affine/components/toolbar'; +import { watch } from '@blocksuite/affine/global/lit'; import { BookmarkBlockModel, EmbedLinkedDocModel, @@ -52,13 +53,16 @@ import { getSelectedModelsCommand } from '@blocksuite/affine/shared/commands'; import { ImageSelection } from '@blocksuite/affine/shared/selection'; import { ActionPlacement, + FeatureFlagService, GenerateDocUrlProvider, + isRemovedUserInfo, type OpenDocMode, type ToolbarAction, type ToolbarActionGroup, type ToolbarContext, type ToolbarModuleConfig, ToolbarModuleExtension, + UserProvider, } from '@blocksuite/affine/shared/services'; import { matchModels } from '@blocksuite/affine/shared/utils'; import type { ExtensionType } from '@blocksuite/affine/store'; @@ -73,11 +77,13 @@ import { OpenInNewIcon, SplitViewIcon, } from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; import type { FrameworkProvider } from '@toeverything/infra'; import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; import { keyed } from 'lit/directives/keyed.js'; import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; import { createCopyAsPngMenuItem } from './copy-as-image'; @@ -297,6 +303,77 @@ function createToolbarMoreMenuConfigV2(baseUrl?: string) { }, ], }, + { + placement: ActionPlacement.More, + id: 'z.block-meta', + actions: [ + { + id: 'block-meta-display', + when: cx => { + const featureFlag = cx.std.get(FeatureFlagService); + const isEnabled = featureFlag.getFlag('enable_block_meta'); + if (!isEnabled) return false; + + // only display when one block is selected by block selection + const { selection, getCurrentBlockBy } = cx; + const hasBlockSelection = + selection.filter(BlockSelection).length === 1; + if (!hasBlockSelection) return false; + const block = getCurrentBlockBy.call(cx, BlockSelection); + if (!block) return false; + + const createdAt = 'meta:createdAt'; + const createdBy = 'meta:createdBy'; + return ( + createdAt in block.model && + block.model[createdAt] !== undefined && + createdBy in block.model && + block.model[createdBy] !== undefined && + typeof block.model[createdBy] === 'string' && + typeof block.model[createdAt] === 'number' + ); + }, + content: cx => { + const model = cx.getCurrentModelBy(BlockSelection); + if (!model) return null; + const createdAt = 'meta:createdAt'; + if (!(createdAt in model)) return null; + const createdBy = 'meta:createdBy'; + if (!(createdBy in model)) return null; + const createdByUserId = model[createdBy] as string; + const createdAtTimestamp = model[createdAt] as number; + const date = new Date(createdAtTimestamp); + const userProvider = cx.std.get(UserProvider); + userProvider.revalidateUserInfo(createdByUserId); + const userSignal = userProvider.userInfo$(createdByUserId); + userSignal.subscribe(console.log); + const user = computed(() => { + const value = userSignal.value; + if (!value) return 'Unknown User'; + const removed = isRemovedUserInfo(value); + if (removed) return 'Deleted User'; + return value.name; + }); + const createdAtString = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + }); + const wrapperStyle = { + padding: '4px 8px', + fontSize: '12px', + }; + return html`
+
Created by ${watch(user)}
+
${createdAtString}
+
`; + }, + }, + ], + }, ], } as const satisfies ToolbarModuleConfig; }