mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-05 11:35:34 +08:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adaee0ef5f | |||
| baf1aad412 | |||
| 231956fd39 | |||
| 73c7815a6d | |||
| 6850871bfb | |||
| 18cb4199fa | |||
| 24c382d3aa | |||
| 8bea31698e | |||
| 94d5a42355 | |||
| b2aa3084ec | |||
| 00c5f48a7d | |||
| 1306a9733b | |||
| 7c41ddb789 | |||
| 57ec22ec2e | |||
| a91193c921 | |||
| 7477ba6d37 | |||
| 9f939d823e | |||
| 61b3f82bfe | |||
| a94bef6738 | |||
| dffb89c388 | |||
| ac815142b3 | |||
| ee66545ac9 | |||
| fcc2ec9d66 | |||
| 317d3e7ea6 | |||
| a709ed2ef1 | |||
| 1b93d3d8d2 | |||
| efab5d4270 | |||
| 64c7fb1d66 | |||
| 387f7211bf | |||
| ebee11f573 | |||
| 85daea6fa8 | |||
| 6c125d9a38 | |||
| 8c582122a8 |
@@ -144,14 +144,18 @@ jobs:
|
||||
name: server-native.node
|
||||
path: ./packages/backend/server
|
||||
|
||||
- name: Prepare Server Test Environment
|
||||
env:
|
||||
COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }}
|
||||
COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }}
|
||||
COPILOT_GOOGLE_API_KEY: ${{ secrets.COPILOT_GOOGLE_API_KEY }}
|
||||
COPILOT_PERPLEXITY_API_KEY: ${{ secrets.COPILOT_PERPLEXITY_API_KEY }}
|
||||
uses: ./.github/actions/server-test-env
|
||||
|
||||
- name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
uses: ./.github/actions/copilot-test
|
||||
with:
|
||||
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
openai-key: ${{ secrets.COPILOT_OPENAI_API_KEY }}
|
||||
google-key: ${{ secrets.COPILOT_GOOGLE_API_KEY }}
|
||||
fal-key: ${{ secrets.COPILOT_FAL_API_KEY }}
|
||||
perplexity-key: ${{ secrets.COPILOT_PERPLEXITY_API_KEY }}
|
||||
|
||||
test-done:
|
||||
needs:
|
||||
|
||||
+330
-330
File diff suppressed because one or more lines are too long
+1
-1
@@ -12,4 +12,4 @@ npmPublishAccess: public
|
||||
|
||||
npmPublishRegistry: "https://registry.npmjs.org"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.7.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.8.0.cjs
|
||||
|
||||
Generated
+2
-2
@@ -2501,9 +2501,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.1"
|
||||
version = "1.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
|
||||
checksum = "c2806eaa3524762875e21c3dcd057bc4b7bfa01ce4da8d46be1cd43649e1cc6b"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
|
||||
+50
-14
@@ -11,10 +11,10 @@ import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ERROR_CARD_DEFAULT_HEIGHT } from '../consts';
|
||||
import type { EmbedIframeStatusCardOptions } from '../types';
|
||||
|
||||
const LINK_EDIT_POPUP_OFFSET = 12;
|
||||
const ERROR_CARD_DEFAULT_HEIGHT = 114;
|
||||
|
||||
export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
@@ -24,7 +24,7 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
}
|
||||
|
||||
.affine-embed-iframe-error-card {
|
||||
container: affine-embed-iframe-error-card / inline-size;
|
||||
container: affine-embed-iframe-error-card / size;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
@@ -41,7 +41,6 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1 0 0;
|
||||
|
||||
.error-title {
|
||||
display: flex;
|
||||
@@ -64,6 +63,9 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 22px; /* 157.143% */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +121,6 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-error-card (width < 480px) {
|
||||
.error-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-error-card.horizontal {
|
||||
@@ -133,12 +129,19 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
|
||||
.error-content {
|
||||
align-items: flex-start;
|
||||
flex: 1 0 0;
|
||||
|
||||
.error-message {
|
||||
height: 40px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-error-card (width < 480px) {
|
||||
.error-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-error-card.vertical {
|
||||
@@ -155,6 +158,18 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
svg {
|
||||
transform: scale(1.6) translateY(-14px);
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-error-card (height < 300px) or (width < 300px) {
|
||||
.error-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -216,10 +231,10 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
<div class=${cardClasses} style=${cardStyle}>
|
||||
<div class="error-content">
|
||||
<div class="error-title">
|
||||
<div class="error-icon">
|
||||
<span class="error-icon">
|
||||
${InformationIcon({ width: '16px', height: '16px' })}
|
||||
</div>
|
||||
<div class="error-title-text">This link couldn’t be loaded.</div>
|
||||
</span>
|
||||
<span class="error-title-text">This link couldn’t be loaded.</span>
|
||||
</div>
|
||||
<div class="error-message">
|
||||
${this.error?.message || 'Failed to load embedded content'}
|
||||
@@ -244,8 +259,7 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-banner">
|
||||
<!-- TODO: add error banner icon -->
|
||||
<div class="icon-box"></div>
|
||||
<div class="icon-box">${EmbedIframeErrorIcon}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -280,3 +294,25 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
height: ERROR_CARD_DEFAULT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
export const EmbedIframeErrorIcon = html`<svg
|
||||
width="204"
|
||||
height="102"
|
||||
viewBox="0 0 204 102"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_2676_106795)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M94.6838 8.45092L106.173 31.9276L84.6593 57.0514L90.5888 64.9202C88.6083 64.6092 86.5089 65.0701 84.7813 66.3719L78.4802 71.1202C75.0967 73.6698 74.4207 78.4796 76.9704 81.8631C79.5201 85.2467 84.3299 85.9227 87.7134 83.373L89.4487 82.0654C90.3714 81.37 90.5558 80.0582 89.8604 79.1354C89.1651 78.2127 87.8533 78.0283 86.9305 78.7237L85.1952 80.0313C83.6573 81.1902 81.471 80.883 80.3121 79.345C79.1531 77.807 79.4604 75.6208 80.9984 74.4618L87.2995 69.7136C88.8375 68.5547 91.0237 68.8619 92.1827 70.3999C92.8645 71.3047 94.1389 71.4996 95.0582 70.8513L95.8982 71.966L94.6469 72.9089C93.109 74.0679 90.9227 73.7606 89.7638 72.2227C89.0684 71.2999 87.7566 71.1155 86.8339 71.8109C85.9111 72.5062 85.7267 73.818 86.4221 74.7408C88.9718 78.1243 93.7816 78.8003 97.1651 76.2506L98.4164 75.3077L99.8156 77.1646L86.8434 102.707L89.291 114.735L42.1397 108.108L56.3354 7.10072C56.6429 4.91308 58.6655 3.38889 60.8532 3.69634L94.6838 8.45092ZM122.987 12.4287L119.974 33.8672L95.4607 58.4925C98.7006 56.8928 102.722 57.7678 104.976 60.7594C107.526 64.1429 106.85 68.9527 103.466 71.5024L102.718 72.0665L105.949 78.0266L92.2105 103.461L92.9872 115.254L147.108 122.86L161.304 21.8531C161.611 19.6654 160.087 17.6428 157.899 17.3353L122.987 12.4287ZM100.701 68.3471L100.948 68.1607C102.486 67.0018 102.793 64.8155 101.634 63.2775C100.625 61.9381 98.8364 61.5321 97.3755 62.2152L100.701 68.3471ZM88.8231 36.502C84.6277 35.9124 80.7486 38.8354 80.159 43.0308L79.1885 49.9367C79.0277 51.0809 79.8249 52.1388 80.9691 52.2996C82.1133 52.4604 83.1712 51.6632 83.332 50.519L84.3025 43.6132C84.5705 41.7062 86.3337 40.3775 88.2407 40.6455L95.1466 41.6161C96.2908 41.7769 97.3487 40.9797 97.5095 39.8355C97.6703 38.6913 96.8731 37.6334 95.7289 37.4726L88.8231 36.502ZM115.065 40.1901C113.921 40.0293 112.863 40.8265 112.702 41.9707C112.542 43.1149 113.339 44.1728 114.483 44.3336L121.389 45.3042C123.296 45.5722 124.625 47.3354 124.357 49.2424L123.386 56.1483C123.225 57.2925 124.022 58.3504 125.167 58.5112C126.311 58.672 127.369 57.8748 127.529 56.7306L128.5 49.8247C129.09 45.6293 126.167 41.7503 121.971 41.1607L115.065 40.1901ZM123.031 73.7041C124.176 73.8649 124.973 74.9228 124.812 76.067L123.841 82.9728C123.252 87.1682 119.373 90.0913 115.177 89.5017L106.89 88.337C105.746 88.1762 104.949 87.1183 105.11 85.9741C105.27 84.8299 106.328 84.0327 107.473 84.1935L115.76 85.3582C117.667 85.6262 119.43 84.2975 119.698 82.3905L120.668 75.4847C120.829 74.3405 121.887 73.5433 123.031 73.7041Z"
|
||||
fill="#E6E6E6"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2676_106795">
|
||||
<rect width="204" height="102" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>`;
|
||||
|
||||
+75
-8
@@ -3,16 +3,22 @@ import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { EmbedIcon } from '@blocksuite/icons/lit';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { IDLE_CARD_DEFAULT_HEIGHT } from '../consts';
|
||||
import type { EmbedIframeStatusCardOptions } from '../types';
|
||||
|
||||
export class EmbedIframeIdleCard extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-idle-card {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
container: affine-embed-iframe-idle-card / size;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -23,8 +29,6 @@ export class EmbedIframeIdleCard extends WithDisposable(LitElement) {
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${unsafeCSSVarV2('icon/secondary')};
|
||||
@@ -48,18 +52,81 @@ export class EmbedIframeIdleCard extends WithDisposable(LitElement) {
|
||||
.affine-embed-iframe-idle-card:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-idle-card.horizontal {
|
||||
flex-direction: row;
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-idle-card.vertical {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
gap: 12px;
|
||||
|
||||
.icon {
|
||||
width: 176px;
|
||||
height: 112px;
|
||||
overflow-y: hidden;
|
||||
|
||||
svg {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
transform: rotate(12deg) translateY(18%);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-idle-card (height < 180px) {
|
||||
.icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const { layout, width, height } = this.options;
|
||||
const cardClasses = classMap({
|
||||
'affine-embed-iframe-idle-card': true,
|
||||
horizontal: layout === 'horizontal',
|
||||
vertical: layout === 'vertical',
|
||||
});
|
||||
|
||||
const cardWidth = width ? `${width}px` : '100%';
|
||||
const cardHeight = height ? `${height}px` : '100%';
|
||||
const cardStyle = styleMap({
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="affine-embed-iframe-idle-card">
|
||||
<span class="icon">
|
||||
${EmbedIcon({ width: '24px', height: '24px' })}
|
||||
</span>
|
||||
<div class=${cardClasses} style=${cardStyle}>
|
||||
<span class="icon"> ${EmbedIcon()} </span>
|
||||
<span class="text">
|
||||
Embed anything (Google Drive, Google Docs, Spotify, Miro…)
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor options: EmbedIframeStatusCardOptions = {
|
||||
layout: 'horizontal',
|
||||
height: IDLE_CARD_DEFAULT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
+1
@@ -104,6 +104,7 @@ export class EmbedIframeLinkInputBase extends WithDisposable(LitElement) {
|
||||
this.disposables.addFromEvent(this, 'cut', stopPropagation);
|
||||
this.disposables.addFromEvent(this, 'copy', stopPropagation);
|
||||
this.disposables.addFromEvent(this, 'paste', stopPropagation);
|
||||
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
|
||||
}
|
||||
|
||||
get store() {
|
||||
|
||||
+8
-3
@@ -8,10 +8,9 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { getEmbedCardIcons } from '../../common/utils';
|
||||
import { LOADING_CARD_DEFAULT_HEIGHT } from '../consts';
|
||||
import type { EmbedIframeStatusCardOptions } from '../types';
|
||||
|
||||
const LOADING_CARD_DEFAULT_HEIGHT = 114;
|
||||
|
||||
export class EmbedIframeLoadingCard extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
@@ -20,7 +19,7 @@ export class EmbedIframeLoadingCard extends LitElement {
|
||||
}
|
||||
|
||||
.affine-embed-iframe-loading-card {
|
||||
container: affine-embed-iframe-loading-card / inline-size;
|
||||
container: affine-embed-iframe-loading-card / size;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
@@ -147,6 +146,12 @@ export class EmbedIframeLoadingCard extends LitElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-loading-card (height < 240px) {
|
||||
.loading-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,3 +6,7 @@ export const DEFAULT_IFRAME_HEIGHT = 152;
|
||||
export const DEFAULT_IFRAME_WIDTH = '100%';
|
||||
|
||||
export const LINK_CREATE_POPUP_OFFSET = 4;
|
||||
|
||||
export const IDLE_CARD_DEFAULT_HEIGHT = 48;
|
||||
export const LOADING_CARD_DEFAULT_HEIGHT = 114;
|
||||
export const ERROR_CARD_DEFAULT_HEIGHT = 114;
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
DEFAULT_IFRAME_HEIGHT,
|
||||
DEFAULT_IFRAME_WIDTH,
|
||||
EMBED_IFRAME_DEFAULT_CONTAINER_BORDER_RADIUS,
|
||||
ERROR_CARD_DEFAULT_HEIGHT,
|
||||
IDLE_CARD_DEFAULT_HEIGHT,
|
||||
LINK_CREATE_POPUP_OFFSET,
|
||||
LOADING_CARD_DEFAULT_HEIGHT,
|
||||
} from './consts.js';
|
||||
import { embedIframeBlockStyles } from './style.js';
|
||||
import type { EmbedIframeStatusCardOptions } from './types.js';
|
||||
@@ -109,10 +112,23 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
return flag ?? false;
|
||||
}
|
||||
|
||||
get _horizontalCardHeight(): number {
|
||||
switch (this.status$.value) {
|
||||
case 'idle':
|
||||
return IDLE_CARD_DEFAULT_HEIGHT;
|
||||
case 'loading':
|
||||
return LOADING_CARD_DEFAULT_HEIGHT;
|
||||
case 'error':
|
||||
return ERROR_CARD_DEFAULT_HEIGHT;
|
||||
default:
|
||||
return LOADING_CARD_DEFAULT_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
get _statusCardOptions(): EmbedIframeStatusCardOptions {
|
||||
return this.inSurface
|
||||
? { layout: 'vertical' }
|
||||
: { layout: 'horizontal', height: 114 };
|
||||
: { layout: 'horizontal', height: this._horizontalCardHeight };
|
||||
}
|
||||
|
||||
open = () => {
|
||||
@@ -257,19 +273,21 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
};
|
||||
|
||||
protected _handleClick = () => {
|
||||
// We don't need to select the block when the block is in the surface
|
||||
if (this.inSurface) {
|
||||
return;
|
||||
}
|
||||
|
||||
// when the block is in idle status and the url is not set, clear the selection
|
||||
// and show the link input popup
|
||||
if (this.isIdle$.value && !this.model.props.url) {
|
||||
this.selectionManager.clear(['block']);
|
||||
// when the block is in the surface, clear the surface selection
|
||||
// otherwise, clear the block selection
|
||||
this.selectionManager.clear([this.inSurface ? 'surface' : 'block']);
|
||||
this.toggleLinkInputPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
// We don't need to select the block when the block is in the surface
|
||||
if (this.inSurface) {
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise, select the block
|
||||
this._selectBlock();
|
||||
};
|
||||
@@ -311,7 +329,9 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
|
||||
private readonly _renderContent = () => {
|
||||
if (this.isIdle$.value) {
|
||||
return html`<embed-iframe-idle-card></embed-iframe-idle-card>`;
|
||||
return html`<embed-iframe-idle-card
|
||||
.options=${this._statusCardOptions}
|
||||
></embed-iframe-idle-card>`;
|
||||
}
|
||||
|
||||
if (this.isLoading$.value) {
|
||||
@@ -356,6 +376,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
})
|
||||
);
|
||||
|
||||
// if the iframe url is not set, refresh the data to get the iframe url
|
||||
if (!this.model.props.iframeUrl) {
|
||||
this.doc.withoutTransact(() => {
|
||||
this.refreshData().catch(console.error);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*",
|
||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
@@ -34,7 +35,8 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
"./effects": "./src/effects.ts",
|
||||
"./turbo-painter": "./src/turbo/list-painter.worker.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
|
||||
@@ -3,3 +3,5 @@ export * from './commands';
|
||||
export { correctNumberedListsOrderToPrev } from './commands/utils';
|
||||
export * from './list-block.js';
|
||||
export * from './list-spec.js';
|
||||
export * from './turbo/list-layout-handler';
|
||||
export * from './turbo/list-painter.worker';
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Rect } from '@blocksuite/affine-gfx-turbo-renderer';
|
||||
import {
|
||||
BlockLayoutHandlerExtension,
|
||||
BlockLayoutHandlersIdentifier,
|
||||
getSentenceRects,
|
||||
segmentSentences,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer';
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import type { GfxBlockComponent } from '@blocksuite/std';
|
||||
import { clientToModelCoord } from '@blocksuite/std/gfx';
|
||||
|
||||
import type { ListLayout } from './list-painter.worker';
|
||||
|
||||
export class ListLayoutHandlerExtension extends BlockLayoutHandlerExtension<ListLayout> {
|
||||
readonly blockType = 'affine:list';
|
||||
|
||||
static override setup(di: Container) {
|
||||
di.addImpl(
|
||||
BlockLayoutHandlersIdentifier('list'),
|
||||
ListLayoutHandlerExtension
|
||||
);
|
||||
}
|
||||
|
||||
queryLayout(component: GfxBlockComponent): ListLayout | null {
|
||||
// Select all list items within this list block
|
||||
const listItemSelector =
|
||||
'.affine-list-block-container .affine-list-rich-text-wrapper [data-v-text="true"]';
|
||||
const listItemNodes = component.querySelectorAll(listItemSelector);
|
||||
|
||||
if (listItemNodes.length === 0) return null;
|
||||
|
||||
const viewportRecord = component.gfx.viewport.deserializeRecord(
|
||||
component.dataset.viewportState
|
||||
);
|
||||
|
||||
if (!viewportRecord) return null;
|
||||
|
||||
const { zoom, viewScale } = viewportRecord;
|
||||
const list: ListLayout = {
|
||||
type: 'affine:list',
|
||||
items: [],
|
||||
};
|
||||
|
||||
listItemNodes.forEach(listItemNode => {
|
||||
const listItemWrapper = listItemNode.closest(
|
||||
'.affine-list-rich-text-wrapper'
|
||||
);
|
||||
if (!listItemWrapper) return;
|
||||
|
||||
// Determine list item type based on class
|
||||
let itemType: 'bulleted' | 'numbered' | 'todo' | 'toggle' = 'bulleted';
|
||||
let checked = false;
|
||||
let collapsed = false;
|
||||
let prefix = '';
|
||||
|
||||
if (listItemWrapper.classList.contains('affine-list--checked')) {
|
||||
checked = true;
|
||||
}
|
||||
|
||||
const parentListBlock = listItemWrapper.closest(
|
||||
'.affine-list-block-container'
|
||||
)?.parentElement;
|
||||
if (parentListBlock) {
|
||||
if (parentListBlock.dataset.listType === 'numbered') {
|
||||
itemType = 'numbered';
|
||||
const orderVal = parentListBlock.dataset.listOrder;
|
||||
if (orderVal) {
|
||||
prefix = orderVal + '.';
|
||||
}
|
||||
} else if (parentListBlock.dataset.listType === 'todo') {
|
||||
itemType = 'todo';
|
||||
} else if (parentListBlock.dataset.listType === 'toggle') {
|
||||
itemType = 'toggle';
|
||||
collapsed = parentListBlock.dataset.collapsed === 'true';
|
||||
} else {
|
||||
itemType = 'bulleted';
|
||||
}
|
||||
}
|
||||
|
||||
const computedStyle = window.getComputedStyle(listItemNode);
|
||||
const fontSizeStr = computedStyle.fontSize;
|
||||
const fontSize = parseInt(fontSizeStr);
|
||||
|
||||
const sentences = segmentSentences(listItemNode.textContent || '');
|
||||
const sentenceLayouts = sentences.map(sentence => {
|
||||
const sentenceRects = getSentenceRects(listItemNode, sentence);
|
||||
return {
|
||||
text: sentence,
|
||||
rects: sentenceRects.map(({ text, rect }) => {
|
||||
const [modelX, modelY] = clientToModelCoord(viewportRecord, [
|
||||
rect.x,
|
||||
rect.y,
|
||||
]);
|
||||
return {
|
||||
text,
|
||||
rect: {
|
||||
x: modelX,
|
||||
y: modelY,
|
||||
w: rect.w / zoom / viewScale,
|
||||
h: rect.h / zoom / viewScale,
|
||||
},
|
||||
};
|
||||
}),
|
||||
fontSize,
|
||||
type: itemType,
|
||||
prefix,
|
||||
checked,
|
||||
collapsed,
|
||||
};
|
||||
});
|
||||
|
||||
list.items.push(...sentenceLayouts);
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
calculateBound(layout: ListLayout) {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
layout.items.forEach(item => {
|
||||
item.rects.forEach(r => {
|
||||
minX = Math.min(minX, r.rect.x);
|
||||
minY = Math.min(minY, r.rect.y);
|
||||
maxX = Math.max(maxX, r.rect.x + r.rect.w);
|
||||
maxY = Math.max(maxY, r.rect.y + r.rect.h);
|
||||
});
|
||||
});
|
||||
|
||||
const rect: Rect = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
w: maxX - minX,
|
||||
h: maxY - minY,
|
||||
};
|
||||
|
||||
return {
|
||||
rect,
|
||||
subRects: layout.items.flatMap(s => s.rects.map(r => r.rect)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
BlockLayout,
|
||||
BlockLayoutPainter,
|
||||
TextRect,
|
||||
WorkerToHostMessage,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer';
|
||||
import {
|
||||
BlockLayoutPainterExtension,
|
||||
getBaseline,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer/painter';
|
||||
|
||||
interface ListItemLayout {
|
||||
text: string;
|
||||
rects: TextRect[];
|
||||
fontSize: number;
|
||||
type: 'bulleted' | 'numbered' | 'todo' | 'toggle';
|
||||
prefix?: string;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface ListLayout extends BlockLayout {
|
||||
type: 'affine:list';
|
||||
items: ListItemLayout[];
|
||||
}
|
||||
|
||||
const debugListBorder = false;
|
||||
|
||||
function isListLayout(layout: BlockLayout): layout is ListLayout {
|
||||
return layout.type === 'affine:list';
|
||||
}
|
||||
|
||||
class ListLayoutPainter implements BlockLayoutPainter {
|
||||
private static readonly supportFontFace =
|
||||
typeof FontFace !== 'undefined' &&
|
||||
typeof self !== 'undefined' &&
|
||||
'fonts' in self;
|
||||
|
||||
static readonly font = ListLayoutPainter.supportFontFace
|
||||
? new FontFace(
|
||||
'Inter',
|
||||
`url(https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwYZ8UA3.woff2)`
|
||||
)
|
||||
: null;
|
||||
|
||||
static fontLoaded = !ListLayoutPainter.supportFontFace;
|
||||
|
||||
static {
|
||||
if (ListLayoutPainter.supportFontFace && ListLayoutPainter.font) {
|
||||
// @ts-expect-error worker fonts API
|
||||
self.fonts.add(ListLayoutPainter.font);
|
||||
|
||||
ListLayoutPainter.font
|
||||
.load()
|
||||
.then(() => {
|
||||
ListLayoutPainter.fontLoaded = true;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load Inter font:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
paint(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
layout: BlockLayout,
|
||||
layoutBaseX: number,
|
||||
layoutBaseY: number
|
||||
): void {
|
||||
if (!ListLayoutPainter.fontLoaded) {
|
||||
const message: WorkerToHostMessage = {
|
||||
type: 'paintError',
|
||||
error: 'Font not loaded',
|
||||
blockType: 'affine:list',
|
||||
};
|
||||
self.postMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isListLayout(layout)) return;
|
||||
|
||||
const renderedPositions = new Set<string>();
|
||||
|
||||
layout.items.forEach(item => {
|
||||
const fontSize = item.fontSize;
|
||||
const baselineY = getBaseline(fontSize);
|
||||
|
||||
ctx.font = `${fontSize}px Inter`;
|
||||
ctx.strokeStyle = 'yellow';
|
||||
// Render the text content
|
||||
item.rects.forEach(textRect => {
|
||||
const x = textRect.rect.x - layoutBaseX;
|
||||
const y = textRect.rect.y - layoutBaseY;
|
||||
|
||||
const posKey = `${x},${y}`;
|
||||
// Only render if we haven't rendered at this position before
|
||||
if (renderedPositions.has(posKey)) return;
|
||||
|
||||
if (debugListBorder) {
|
||||
ctx.strokeRect(x, y, textRect.rect.w, textRect.rect.h);
|
||||
}
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(textRect.text, x, y + baselineY);
|
||||
|
||||
renderedPositions.add(posKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ListLayoutPainterExtension = BlockLayoutPainterExtension(
|
||||
'affine:list',
|
||||
ListLayoutPainter
|
||||
);
|
||||
@@ -8,6 +8,7 @@
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../gfx/turbo-renderer" },
|
||||
{ "path": "../../inlines/preset" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
|
||||
@@ -216,6 +216,8 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
const { borderRadius } = edgeless.style;
|
||||
const { collapse = false, collapsedHeight, scale = 1 } = edgeless;
|
||||
|
||||
const { tool } = this.gfx;
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const height = bound.h / scale;
|
||||
|
||||
@@ -280,7 +282,9 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
.editing=${this._editing}
|
||||
></edgeless-note-mask>
|
||||
|
||||
${isCollapsable && (!this.model.isPageBlock() || !hasHeader)
|
||||
${isCollapsable &&
|
||||
tool.currentToolName$.value !== 'frameNavigator' &&
|
||||
(!this.model.isPageBlock() || !hasHeader)
|
||||
? html`<div
|
||||
class="${classMap({
|
||||
[styles.collapseButton]: true,
|
||||
|
||||
@@ -3,5 +3,5 @@ export * from './commands';
|
||||
export * from './paragraph-block.js';
|
||||
export * from './paragraph-block-config.js';
|
||||
export * from './paragraph-spec.js';
|
||||
export * from './turbo/paragraph-layout-provider.js';
|
||||
export * from './turbo/paragraph-painter.worker.js';
|
||||
export * from './turbo/paragraph-layout-handler';
|
||||
export * from './turbo/paragraph-painter.worker';
|
||||
|
||||
+4
-2
@@ -15,8 +15,10 @@ export class ParagraphLayoutHandlerExtension extends BlockLayoutHandlerExtension
|
||||
readonly blockType = 'affine:paragraph';
|
||||
|
||||
static override setup(di: Container) {
|
||||
const layoutHandler = new ParagraphLayoutHandlerExtension();
|
||||
di.addImpl(BlockLayoutHandlersIdentifier, layoutHandler);
|
||||
di.addImpl(
|
||||
BlockLayoutHandlersIdentifier('paragraph'),
|
||||
ParagraphLayoutHandlerExtension
|
||||
);
|
||||
}
|
||||
|
||||
queryLayout(component: GfxBlockComponent): ParagraphLayout | null {
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
TextRect,
|
||||
WorkerToHostMessage,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer';
|
||||
import { BlockLayoutPainterExtension } from '@blocksuite/affine-gfx-turbo-renderer/painter';
|
||||
import {
|
||||
BlockLayoutPainterExtension,
|
||||
getBaseline,
|
||||
} from '@blocksuite/affine-gfx-turbo-renderer/painter';
|
||||
|
||||
interface SentenceLayout {
|
||||
text: string;
|
||||
@@ -17,25 +20,8 @@ export interface ParagraphLayout extends BlockLayout {
|
||||
sentences: SentenceLayout[];
|
||||
}
|
||||
|
||||
const meta = {
|
||||
emSize: 2048,
|
||||
hHeadAscent: 1984,
|
||||
hHeadDescent: -494,
|
||||
};
|
||||
|
||||
const debugSentenceBorder = false;
|
||||
|
||||
function getBaseline(fontSize: number) {
|
||||
const lineHeight = 1.2 * fontSize;
|
||||
|
||||
const A = fontSize * (meta.hHeadAscent / meta.emSize); // ascent
|
||||
const D = fontSize * (meta.hHeadDescent / meta.emSize); // descent
|
||||
const AD = A + Math.abs(D); // ascent + descent
|
||||
const L = lineHeight - AD; // leading
|
||||
const y = A + L / 2;
|
||||
return y;
|
||||
}
|
||||
|
||||
function isParagraphLayout(layout: BlockLayout): layout is ParagraphLayout {
|
||||
return layout.type === 'affine:paragraph';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { FileDropExtension } from '@blocksuite/affine-components/drop-indicator';
|
||||
import { ConnectorElementView } from '@blocksuite/affine-gfx-connector';
|
||||
import { GroupElementView } from '@blocksuite/affine-gfx-group';
|
||||
import { MindMapView } from '@blocksuite/affine-gfx-mindmap';
|
||||
import { ShapeElementView } from '@blocksuite/affine-gfx-shape';
|
||||
import { TextElementView } from '@blocksuite/affine-gfx-text';
|
||||
import { NoteBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
DNDAPIExtension,
|
||||
@@ -27,6 +32,19 @@ import {
|
||||
viewportOverlayWidget,
|
||||
} from './widgets';
|
||||
|
||||
/**
|
||||
* Why do we add these extensions into CommonSpecs?
|
||||
* Because in some cases we need to create edgeless elements in page mode.
|
||||
* And these view may contain some logic when elements initialize.
|
||||
*/
|
||||
const EdgelessElementViews = [
|
||||
ConnectorElementView,
|
||||
MindMapView,
|
||||
GroupElementView,
|
||||
TextElementView,
|
||||
ShapeElementView,
|
||||
];
|
||||
|
||||
export const CommonSpecs: ExtensionType[] = [
|
||||
FlavourExtension('affine:page'),
|
||||
DocModeService,
|
||||
@@ -38,6 +56,7 @@ export const CommonSpecs: ExtensionType[] = [
|
||||
ToolbarRegistryExtension,
|
||||
...RootBlockAdapterExtensions,
|
||||
...clipboardConfigs,
|
||||
...EdgelessElementViews,
|
||||
|
||||
modalWidget,
|
||||
innerModalWidget,
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { ConnectorElementView } from '@blocksuite/affine-gfx-connector';
|
||||
import { GroupElementView } from '@blocksuite/affine-gfx-group';
|
||||
import { MindMapView } from '@blocksuite/affine-gfx-mindmap';
|
||||
import { ShapeElementView } from '@blocksuite/affine-gfx-shape';
|
||||
import { TextElementView } from '@blocksuite/affine-gfx-text';
|
||||
import { ViewportElementExtension } from '@blocksuite/affine-shared/services';
|
||||
import { autoConnectWidget } from '@blocksuite/affine-widget-edgeless-auto-connect';
|
||||
import { edgelessToolbarWidget } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
@@ -90,20 +85,11 @@ const EdgelessClipboardConfigs: ExtensionType[] = [
|
||||
EdgelessClipboardEmbedSyncedDocConfig,
|
||||
];
|
||||
|
||||
export const gfxElementViews = [
|
||||
ConnectorElementView,
|
||||
MindMapView,
|
||||
GroupElementView,
|
||||
TextElementView,
|
||||
ShapeElementView,
|
||||
];
|
||||
|
||||
const EdgelessCommonExtension: ExtensionType[] = [
|
||||
CommonSpecs,
|
||||
ToolController,
|
||||
EdgelessRootService,
|
||||
ViewportElementExtension('.affine-edgeless-viewport'),
|
||||
...gfxElementViews,
|
||||
...quickTools,
|
||||
...seniorTools,
|
||||
...EdgelessClipboardConfigs,
|
||||
|
||||
@@ -105,3 +105,20 @@ export class ViewportLayoutPainter {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const meta = {
|
||||
emSize: 2048,
|
||||
hHeadAscent: 1984,
|
||||
hHeadDescent: -494,
|
||||
};
|
||||
|
||||
export function getBaseline(fontSize: number) {
|
||||
const lineHeight = 1.2 * fontSize;
|
||||
|
||||
const A = fontSize * (meta.hHeadAscent / meta.emSize); // ascent
|
||||
const D = fontSize * (meta.hHeadDescent / meta.emSize); // descent
|
||||
const AD = A + Math.abs(D); // ascent + descent
|
||||
const L = lineHeight - AD; // leading
|
||||
const y = A + L / 2;
|
||||
return y;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-callout": "workspace:*",
|
||||
"@blocksuite/affine-block-embed": "workspace:*",
|
||||
"@blocksuite/affine-block-list": "workspace:*",
|
||||
"@blocksuite/affine-block-note": "workspace:*",
|
||||
"@blocksuite/affine-block-paragraph": "workspace:*",
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import {
|
||||
EmbedIcon,
|
||||
FrameIcon,
|
||||
ImageIcon,
|
||||
PageIcon,
|
||||
ShapeIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
const BLOCK_PREVIEW_ICON_MAP: Record<
|
||||
string,
|
||||
{
|
||||
icon: typeof ShapeIcon;
|
||||
name: string;
|
||||
}
|
||||
> = {
|
||||
shape: {
|
||||
icon: ShapeIcon,
|
||||
name: 'Edgeless shape',
|
||||
},
|
||||
'affine:image': {
|
||||
icon: ImageIcon,
|
||||
name: 'Image block',
|
||||
},
|
||||
'affine:note': {
|
||||
icon: PageIcon,
|
||||
name: 'Note block',
|
||||
},
|
||||
'affine:frame': {
|
||||
icon: FrameIcon,
|
||||
name: 'Frame block',
|
||||
},
|
||||
'affine:embed-': {
|
||||
icon: EmbedIcon,
|
||||
name: 'Embed block',
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-dnd-preview-element': EdgelessDndPreviewElement;
|
||||
}
|
||||
}
|
||||
|
||||
export const EDGELESS_DND_PREVIEW_ELEMENT = 'edgeless-dnd-preview-element';
|
||||
|
||||
export class EdgelessDndPreviewElement extends LitElement {
|
||||
static override styles = css`
|
||||
.edgeless-dnd-preview-container {
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
width: 264px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.edgeless-dnd-preview-block {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
|
||||
width: 234px;
|
||||
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
|
||||
border-radius: 8px;
|
||||
background-color: ${unsafeCSSVarV2(
|
||||
'layer/background/overlayPanel',
|
||||
'#FBFBFC'
|
||||
)};
|
||||
|
||||
padding: 8px 20px;
|
||||
gap: 8px;
|
||||
|
||||
transform-origin: center;
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
box-shadow: 0px 0px 0px 0.5px #e3e3e4 inset;
|
||||
}
|
||||
|
||||
.edgeless-dnd-preview-block > svg {
|
||||
color: ${unsafeCSSVarV2('icon/primary', '#77757D')};
|
||||
}
|
||||
|
||||
.edgeless-dnd-preview-block > .text {
|
||||
color: ${unsafeCSSVarV2('text/primary', '#121212')};
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ type: Array })
|
||||
accessor elementTypes: {
|
||||
type: string;
|
||||
}[] = [];
|
||||
|
||||
private _getPreviewIcon(type: string) {
|
||||
if (BLOCK_PREVIEW_ICON_MAP[type]) {
|
||||
return BLOCK_PREVIEW_ICON_MAP[type];
|
||||
}
|
||||
|
||||
if (type.startsWith('affine:embed-')) {
|
||||
return BLOCK_PREVIEW_ICON_MAP['affine:embed-'];
|
||||
}
|
||||
|
||||
return {
|
||||
icon: ShapeIcon,
|
||||
name: 'Edgeless content',
|
||||
};
|
||||
}
|
||||
|
||||
override render() {
|
||||
const blocks = repeat(this.elementTypes.slice(0, 3), ({ type }, index) => {
|
||||
const { icon, name } = this._getPreviewIcon(type);
|
||||
|
||||
return html`<div
|
||||
class="edgeless-dnd-preview-block"
|
||||
style=${styleMap({
|
||||
transform: `rotate(${index * -2}deg)`,
|
||||
zIndex: 3 - index,
|
||||
})}
|
||||
>
|
||||
${icon({ width: '24px', height: '24px' })}
|
||||
<span class="text">${name}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
return html`<div class="edgeless-dnd-preview-container">${blocks}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import {
|
||||
EDGELESS_DND_PREVIEW_ELEMENT,
|
||||
EdgelessDndPreviewElement,
|
||||
} from './components/edgeless-preview/preview';
|
||||
import { AFFINE_DRAG_HANDLE_WIDGET } from './consts';
|
||||
import { AffineDragHandleWidget } from './drag-handle';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(AFFINE_DRAG_HANDLE_WIDGET, AffineDragHandleWidget);
|
||||
customElements.define(
|
||||
EDGELESS_DND_PREVIEW_ELEMENT,
|
||||
EdgelessDndPreviewElement
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||
import { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
DocModeExtension,
|
||||
DocModeProvider,
|
||||
EditorSettingExtension,
|
||||
EditorSettingProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { matchModels, SpecProvider } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type BlockComponent,
|
||||
BlockStdScope,
|
||||
BlockViewIdentifier,
|
||||
LifeCycleWatcher,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { SpecProvider } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockStdScope, BlockViewIdentifier } from '@blocksuite/std';
|
||||
import type {
|
||||
BlockModel,
|
||||
BlockViewType,
|
||||
@@ -24,14 +16,11 @@ import type {
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { EdgelessDndPreviewElement } from '../components/edgeless-preview/preview.js';
|
||||
import type { AffineDragHandleWidget } from '../drag-handle.js';
|
||||
import { getSnapshotRect } from '../utils.js';
|
||||
|
||||
export class PreviewHelper {
|
||||
private readonly _calculateQuery = (
|
||||
selectedIds: string[],
|
||||
mode: 'block' | 'gfx'
|
||||
): Query => {
|
||||
private readonly _calculateQuery = (selectedIds: string[]): Query => {
|
||||
const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map(
|
||||
id => ({
|
||||
id,
|
||||
@@ -58,22 +47,10 @@ export class PreviewHelper {
|
||||
}
|
||||
|
||||
const children = model.children ?? [];
|
||||
if (
|
||||
mode === 'gfx' &&
|
||||
matchModels(model, [RootBlockModel, SurfaceBlockModel])
|
||||
) {
|
||||
children.forEach(child => {
|
||||
if (selectedIds.includes(child.id)) {
|
||||
ids.push({ viewType: 'display', id: child.id });
|
||||
addChildren(child.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
children.forEach(child => {
|
||||
ids.push({ viewType: 'display', id: child.id });
|
||||
addChildren(child.id);
|
||||
});
|
||||
}
|
||||
children.forEach(child => {
|
||||
ids.push({ viewType: 'display', id: child.id });
|
||||
addChildren(child.id);
|
||||
});
|
||||
};
|
||||
selectedIds.forEach(addChildren);
|
||||
|
||||
@@ -83,28 +60,16 @@ export class PreviewHelper {
|
||||
};
|
||||
};
|
||||
|
||||
getPreviewStd = (
|
||||
blockIds: string[],
|
||||
snapshot: SliceSnapshot,
|
||||
mode: 'block' | 'gfx'
|
||||
) => {
|
||||
getPreviewStd = (blockIds: string[]) => {
|
||||
const widget = this.widget;
|
||||
const std = widget.std;
|
||||
const sourceGfx = std.get(GfxControllerIdentifier);
|
||||
const isEdgeless = mode === 'gfx';
|
||||
blockIds = blockIds.slice();
|
||||
|
||||
if (isEdgeless) {
|
||||
blockIds.push(sourceGfx.surface!.id, std.store.root!.id);
|
||||
}
|
||||
|
||||
const docModeService = std.get(DocModeProvider);
|
||||
const editorSetting = std.get(EditorSettingProvider).peek();
|
||||
const query = this._calculateQuery(blockIds as string[], mode);
|
||||
const query = this._calculateQuery(blockIds as string[]);
|
||||
const store = widget.doc.doc.getStore({ query });
|
||||
const previewSpec = SpecProvider._.getSpec(
|
||||
isEdgeless ? 'preview:edgeless' : 'preview:page'
|
||||
);
|
||||
const previewSpec = SpecProvider._.getSpec('preview:page');
|
||||
const settingSignal = signal({ ...editorSetting });
|
||||
const extensions = [
|
||||
DocModeExtension(docModeService),
|
||||
@@ -134,35 +99,6 @@ export class PreviewHelper {
|
||||
} as ExtensionType,
|
||||
];
|
||||
|
||||
if (isEdgeless) {
|
||||
class PreviewViewportInitializer extends LifeCycleWatcher {
|
||||
static override key = 'preview-viewport-initializer';
|
||||
|
||||
override mounted(): void {
|
||||
const rect = getSnapshotRect(snapshot);
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.std.view.viewUpdated.subscribe(payload => {
|
||||
if (payload.type !== 'block') return;
|
||||
|
||||
if (payload.view.model.flavour === 'affine:page') {
|
||||
const gfx = this.std.get(GfxControllerIdentifier);
|
||||
|
||||
(
|
||||
payload.view as BlockComponent & { overrideBackground: string }
|
||||
).overrideBackground = 'transparent';
|
||||
|
||||
gfx.viewport.setViewportByBound(rect);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extensions.push(PreviewViewportInitializer);
|
||||
}
|
||||
|
||||
previewSpec.extend(extensions);
|
||||
|
||||
settingSignal.value = {
|
||||
@@ -177,50 +113,92 @@ export class PreviewHelper {
|
||||
|
||||
let width: number = 500;
|
||||
let height;
|
||||
let scale = 1;
|
||||
|
||||
if (isEdgeless) {
|
||||
const rect = getSnapshotRect(snapshot);
|
||||
if (rect) {
|
||||
width = rect.w;
|
||||
height = rect.h;
|
||||
} else {
|
||||
height = 500;
|
||||
}
|
||||
scale = sourceGfx.viewport.zoom;
|
||||
} else {
|
||||
const noteBlock = this.widget.host.querySelector('affine-note');
|
||||
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
|
||||
}
|
||||
const noteBlock = this.widget.host.querySelector('affine-note');
|
||||
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
|
||||
|
||||
return {
|
||||
scale,
|
||||
previewStd,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
private _extractBlockTypes(snapshot: SliceSnapshot) {
|
||||
const blockTypes: {
|
||||
type: string;
|
||||
}[] = [];
|
||||
|
||||
snapshot.content.forEach(block => {
|
||||
if (block.flavour === 'affine:surface') {
|
||||
Object.values(
|
||||
block.props.elements as Record<string, { id: string; type: string }>
|
||||
).forEach(elem => {
|
||||
blockTypes.push({
|
||||
type: elem.type,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
blockTypes.push({
|
||||
type: block.flavour,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return blockTypes;
|
||||
}
|
||||
|
||||
getPreviewElement = (options: {
|
||||
blockIds: string[];
|
||||
snapshot: SliceSnapshot;
|
||||
mode: 'block' | 'gfx';
|
||||
}) => {
|
||||
const { blockIds, snapshot, mode } = options;
|
||||
|
||||
if (mode === 'block') {
|
||||
const { previewStd, width, height } = this.getPreviewStd(blockIds);
|
||||
const previewTemplate = previewStd.render();
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
element: previewTemplate,
|
||||
};
|
||||
} else {
|
||||
const blockTypes = this._extractBlockTypes(snapshot);
|
||||
|
||||
const edgelessPreview = new EdgelessDndPreviewElement();
|
||||
edgelessPreview.elementTypes = blockTypes;
|
||||
|
||||
return {
|
||||
left: 12,
|
||||
top: 12,
|
||||
element: edgelessPreview,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
renderDragPreview = (options: {
|
||||
blockIds: string[];
|
||||
snapshot: SliceSnapshot;
|
||||
container: HTMLElement;
|
||||
mode: 'block' | 'gfx';
|
||||
}): void => {
|
||||
const { blockIds, snapshot, container, mode } = options;
|
||||
const { previewStd, width, height, scale } = this.getPreviewStd(
|
||||
blockIds,
|
||||
snapshot,
|
||||
mode
|
||||
);
|
||||
const previewTemplate = previewStd.render();
|
||||
}): { x: number; y: number } => {
|
||||
const { container } = options;
|
||||
const { width, height, element, left, top } =
|
||||
this.getPreviewElement(options);
|
||||
|
||||
container.style.transform = `scale(${scale})`;
|
||||
container.style.width = `${width}px`;
|
||||
if (height) {
|
||||
container.style.height = `${height}px`;
|
||||
}
|
||||
container.append(previewTemplate);
|
||||
container.style.position = 'absolute';
|
||||
container.style.left = left ? `${left}px` : '';
|
||||
container.style.top = top ? `${top}px` : '';
|
||||
container.style.width = width ? `${width}px` : '';
|
||||
container.style.height = height ? `${height}px` : '';
|
||||
container.append(element);
|
||||
|
||||
return {
|
||||
x: left ?? 0,
|
||||
y: top ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
constructor(readonly widget: AffineDragHandleWidget) {}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
|
||||
import { DropIndicator } from '@blocksuite/affine-components/drop-indicator';
|
||||
import {
|
||||
@@ -511,6 +515,7 @@ export class DragEventWatcher {
|
||||
this._mergeSnapshotToCurDoc(snapshot, point).catch(console.error);
|
||||
} else {
|
||||
this._dropAsGfxBlock(snapshot, point);
|
||||
this.widget.selectionHelper.selection.clear(['block']);
|
||||
}
|
||||
} else {
|
||||
this._onPageDrop(dropBlock, dragPayload, dropPayload, point);
|
||||
@@ -1052,7 +1057,10 @@ export class DragEventWatcher {
|
||||
Bound.deserialize(block.props.xywh as SerializedXYWH) ??
|
||||
new Bound(0, 0, 0, 0);
|
||||
|
||||
if (
|
||||
if (block.flavour === 'affine:embed-iframe') {
|
||||
blockBound.w = EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
|
||||
blockBound.h = EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
|
||||
} else if (
|
||||
block.flavour === 'affine:attachment' ||
|
||||
block.flavour === 'affine:bookmark' ||
|
||||
block.flavour.startsWith('affine:embed-')
|
||||
@@ -1132,17 +1140,22 @@ export class DragEventWatcher {
|
||||
this._dropToModel(surfaceSnapshot, this.gfx.surface!.id)
|
||||
.then(slices => {
|
||||
slices?.content.forEach((block, idx) => {
|
||||
if (
|
||||
block.id === content[idx].id &&
|
||||
(block.flavour === 'affine:image' ||
|
||||
if (block.id === content[idx].id) {
|
||||
if (block.flavour === 'affine:embed-iframe') {
|
||||
store.updateBlock(block.id, {
|
||||
xywh: content[idx].props.xywh,
|
||||
});
|
||||
} else if (
|
||||
block.flavour === 'affine:image' ||
|
||||
block.flavour === 'affine:attachment' ||
|
||||
block.flavour === 'affine:bookmark' ||
|
||||
block.flavour.startsWith('affine:embed-'))
|
||||
) {
|
||||
store.updateBlock(block.id, {
|
||||
xywh: content[idx].props.xywh,
|
||||
style: content[idx].props.style,
|
||||
});
|
||||
block.flavour.startsWith('affine:embed-')
|
||||
) {
|
||||
store.updateBlock(block.id, {
|
||||
xywh: content[idx].props.xywh,
|
||||
style: content[idx].props.style,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -1160,7 +1173,11 @@ export class DragEventWatcher {
|
||||
this._dropToModel(pageSnapshot, this.widget.doc.root!.id)
|
||||
.then(slices => {
|
||||
slices?.content.forEach((block, idx) => {
|
||||
if (
|
||||
if (block.flavour === 'affine:embed-iframe') {
|
||||
store.updateBlock(block.id, {
|
||||
xywh: content[idx].props.xywh,
|
||||
});
|
||||
} else if (
|
||||
block.flavour === 'affine:attachment' ||
|
||||
block.flavour.startsWith('affine:embed-')
|
||||
) {
|
||||
@@ -1403,14 +1420,14 @@ export class DragEventWatcher {
|
||||
|
||||
const { snapshot, fromMode } = source.data.bsEntity;
|
||||
|
||||
this.previewHelper.renderDragPreview({
|
||||
const offset = this.previewHelper.renderDragPreview({
|
||||
blockIds: source.data?.bsEntity?.modelIds,
|
||||
snapshot,
|
||||
container,
|
||||
mode: fromMode ?? 'block',
|
||||
});
|
||||
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setOffset(offset);
|
||||
},
|
||||
setDragData: () => {
|
||||
const { fromMode, snapshot } = this._getDraggedSnapshot();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/block-callout" },
|
||||
{ "path": "../../blocks/block-embed" },
|
||||
{ "path": "../../blocks/block-list" },
|
||||
{ "path": "../../blocks/block-note" },
|
||||
{ "path": "../../blocks/block-paragraph" },
|
||||
|
||||
@@ -144,7 +144,7 @@ export class Viewport {
|
||||
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
|
||||
const newCenterY = initialTopLeftY + height / (2 * this.zoom);
|
||||
|
||||
this.setCenter(newCenterX, newCenterY);
|
||||
this.setCenter(newCenterX, newCenterY, false);
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
this._left = left;
|
||||
@@ -362,7 +362,7 @@ export class Viewport {
|
||||
* @param centerY The new y coordinate of the center of the viewport.
|
||||
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
|
||||
*/
|
||||
setCenter(centerX: number, centerY: number, forceUpdate = false) {
|
||||
setCenter(centerX: number, centerY: number, forceUpdate = true) {
|
||||
if (forceUpdate && this._isResizing) {
|
||||
this._forceCompleteResize();
|
||||
}
|
||||
@@ -405,7 +405,7 @@ export class Viewport {
|
||||
newZoom: number,
|
||||
newCenter = Vec.toVec(this.center),
|
||||
smooth = false,
|
||||
forceUpdate = smooth
|
||||
forceUpdate = true
|
||||
) {
|
||||
// Force complete any pending resize operations if forceUpdate is true
|
||||
if (forceUpdate && this._isResizing) {
|
||||
@@ -445,7 +445,7 @@ export class Viewport {
|
||||
bound: Bound,
|
||||
padding: [number, number, number, number] = [0, 0, 0, 0],
|
||||
smooth = false,
|
||||
forceUpdate = smooth
|
||||
forceUpdate = true
|
||||
) {
|
||||
let [pt, pr, pb, pl] = padding;
|
||||
|
||||
@@ -511,7 +511,7 @@ export class Viewport {
|
||||
zoom: number,
|
||||
focusPoint?: IPoint,
|
||||
wheel = false,
|
||||
forceUpdate = false
|
||||
forceUpdate = true
|
||||
) {
|
||||
if (forceUpdate && this._isResizing) {
|
||||
this._forceCompleteResize();
|
||||
|
||||
+3
-3
@@ -71,13 +71,13 @@
|
||||
"electron": "^35.0.0",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-import-resolver-typescript": "^4.0.0",
|
||||
"eslint-plugin-import-x": "^4.5.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-sonarjs": "^3.0.1",
|
||||
"eslint-plugin-unicorn": "^57.0.0",
|
||||
"eslint-plugin-unicorn": "^58.0.0",
|
||||
"happy-dom": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.11",
|
||||
@@ -92,7 +92,7 @@
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "3.0.9"
|
||||
},
|
||||
"packageManager": "yarn@4.7.0",
|
||||
"packageManager": "yarn@4.8.0",
|
||||
"resolutions": {
|
||||
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@^1",
|
||||
"array-includes": "npm:@nolyfill/array-includes@^1",
|
||||
|
||||
@@ -26,24 +26,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^1.1.19",
|
||||
"@apollo/server": "^4.11.2",
|
||||
"@apollo/server": "^4.11.3",
|
||||
"@aws-sdk/client-s3": "^3.709.0",
|
||||
"@fal-ai/serverless-client": "^0.15.0",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.20.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.4.1",
|
||||
"@google-cloud/opentelemetry-resource-util": "^2.4.0",
|
||||
"@nestjs-cls/transactional": "^2.4.4",
|
||||
"@nestjs-cls/transactional-adapter-prisma": "^1.2.7",
|
||||
"@nestjs/apollo": "^12.2.2",
|
||||
"@nestjs/bullmq": "^10.2.3",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/graphql": "^12.2.2",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/platform-socket.io": "^10.4.15",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/throttler": "6.4.0",
|
||||
"@nestjs/websockets": "^10.4.15",
|
||||
"@nestjs-cls/transactional": "^2.6.1",
|
||||
"@nestjs-cls/transactional-adapter-prisma": "^1.2.19",
|
||||
"@nestjs/apollo": "^13.0.4",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.12",
|
||||
"@nestjs/core": "^11.0.12",
|
||||
"@nestjs/graphql": "^13.0.4",
|
||||
"@nestjs/platform-express": "^11.0.12",
|
||||
"@nestjs/platform-socket.io": "^11.0.12",
|
||||
"@nestjs/schedule": "^5.0.1",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/websockets": "^11.0.12",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@node-rs/crc32": "^1.10.6",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
@@ -73,7 +73,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"eventemitter2": "^6.4.9",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^4.21.2",
|
||||
"express": "^5.0.1",
|
||||
"fast-xml-parser": "^5.0.0",
|
||||
"get-stream": "^9.0.1",
|
||||
"graphql": "^16.9.0",
|
||||
@@ -120,7 +120,8 @@
|
||||
"@faker-js/faker": "^9.6.0",
|
||||
"@nestjs/testing": "patch:@nestjs/testing@npm%3A10.4.15#~/.yarn/patches/@nestjs-testing-npm-10.4.15-d591a1705a.patch",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/express-serve-static-core": "^5.0.6",
|
||||
"@types/graphql-upload": "^17.0.0",
|
||||
"@types/http-errors": "^2.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
||||
@@ -37,7 +37,7 @@ export type ConfigDescriptor<T> = {
|
||||
type ConfigDefineDescriptor<T> = {
|
||||
desc: string;
|
||||
default: T;
|
||||
validate?: (value: T) => boolean;
|
||||
validate?: (value: T) => z.SafeParseReturnType<T, T>;
|
||||
shape?: z.ZodType<T>;
|
||||
env?: string | [string, EnvConfigType];
|
||||
link?: string;
|
||||
@@ -158,7 +158,7 @@ function standardizeDescriptor<T>(
|
||||
default: desc.default,
|
||||
type,
|
||||
validate: (value: T) => {
|
||||
return shape.safeParse(value);
|
||||
return desc.validate ? desc.validate(value) : shape.safeParse(value);
|
||||
},
|
||||
env,
|
||||
link: desc.link,
|
||||
@@ -257,7 +257,15 @@ export function getDefaultConfig(): AppConfigSchema {
|
||||
const { success, error } = desc.validate(defaultValue);
|
||||
|
||||
if (!success) {
|
||||
throw error;
|
||||
throw new Error(
|
||||
error.issues
|
||||
.map(issue => {
|
||||
return `Invalid config for module [${module}] with key [${key}]
|
||||
Value: ${JSON.stringify(defaultValue)}
|
||||
Error: ${issue.message}`;
|
||||
})
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
set(modulizedConfig, key, defaultValue);
|
||||
|
||||
@@ -23,7 +23,7 @@ defineModuleConfig('redis', {
|
||||
desc: 'The database index of redis server to be used(Must be less than 10).',
|
||||
default: 0,
|
||||
env: ['REDIS_SERVER_DATABASE', 'integer'],
|
||||
validate: val => val >= 0 && val < 10,
|
||||
shape: z.number().int().nonnegative().max(10),
|
||||
},
|
||||
host: {
|
||||
desc: 'The host of the redis server.',
|
||||
|
||||
@@ -9,12 +9,12 @@ export interface ServerFlags {
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
server: {
|
||||
externalUrl: string;
|
||||
externalUrl?: string;
|
||||
https: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
name: string | undefined;
|
||||
name?: string;
|
||||
};
|
||||
flags: ServerFlags;
|
||||
}
|
||||
@@ -29,9 +29,16 @@ defineModuleConfig('server', {
|
||||
desc: `Base url of AFFiNE server, used for generating external urls.
|
||||
Default to be \`[server.protocol]://[server.host][:server.port]\` if not specified.
|
||||
`,
|
||||
default: 'http://localhost:3010',
|
||||
default: '',
|
||||
env: 'AFFINE_SERVER_EXTERNAL_URL',
|
||||
shape: z.string().url(),
|
||||
validate: val => {
|
||||
// allow to be nullable and empty string
|
||||
if (!val) {
|
||||
return { success: true, data: val };
|
||||
}
|
||||
|
||||
return z.string().url().safeParse(val);
|
||||
},
|
||||
},
|
||||
https: {
|
||||
desc: 'Whether the server is hosted on a ssl enabled domain (https://).',
|
||||
|
||||
@@ -60,7 +60,7 @@ export class DocRendererController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/*')
|
||||
@Get('/*path')
|
||||
async render(@Req() req: Request, @Res() res: Response) {
|
||||
const assets: HtmlAssets =
|
||||
env.namespaces.canary &&
|
||||
|
||||
@@ -62,7 +62,7 @@ export class StaticFilesResolver implements OnModuleInit {
|
||||
|
||||
// fallback all unknown routes
|
||||
app.get(
|
||||
[basePath + '/admin', basePath + '/admin/*'],
|
||||
[basePath + '/admin', basePath + '/admin/*path'],
|
||||
this.check.use,
|
||||
(_req, res) => {
|
||||
res.sendFile(
|
||||
@@ -101,11 +101,13 @@ export class StaticFilesResolver implements OnModuleInit {
|
||||
redirect: false,
|
||||
index: false,
|
||||
fallthrough: true,
|
||||
immutable: true,
|
||||
dotfiles: 'ignore',
|
||||
})
|
||||
);
|
||||
|
||||
// fallback all unknown routes
|
||||
app.get([basePath, basePath + '/*'], this.check.use, (req, res) => {
|
||||
app.get([basePath, basePath + '/*path'], this.check.use, (req, res) => {
|
||||
const mobile =
|
||||
env.namespaces.canary &&
|
||||
isMobile({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { EditorSettingService } from '@affine/core/modules/editor-setting';
|
||||
import { JournalService } from '@affine/core/modules/journal';
|
||||
@@ -24,14 +25,14 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
events?.applicationMenu.openAboutPageInSettingModal(() => {
|
||||
events?.applicationMenu.openInSettingModal(activeTab => {
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
workspace.scope.get(WorkspaceDialogService).open('setting', {
|
||||
activeTab: 'about',
|
||||
activeTab: activeTab as unknown as SettingTab,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DocProps } from '@affine/core/blocksuite/initialization';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { EditorSettingService } from '@affine/core/modules/editor-setting';
|
||||
import { AudioAttachmentService } from '@affine/core/modules/media/services/audio-attachment';
|
||||
import { MeetingSettingsService } from '@affine/core/modules/media/services/meeting-settings';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
@@ -11,7 +12,7 @@ import { Text } from '@blocksuite/affine/store';
|
||||
import type { BlobEngine } from '@blocksuite/affine/sync';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
import { getCurrentWorkspace } from './utils';
|
||||
import { getCurrentWorkspace, isAiEnabled } from './utils';
|
||||
|
||||
const logger = new DebugLogger('electron-renderer:recording');
|
||||
|
||||
@@ -34,6 +35,8 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
if ((await apis?.ui.isActiveTab()) && status?.status === 'ready') {
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
// maybe the workspace is not ready yet, eg. for shared workspace view
|
||||
await apis?.recording.handleBlockCreationFailed(status.id);
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
@@ -41,6 +44,7 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
frameworkProvider.get(EditorSettingService);
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
const editorSetting = editorSettingService.editorSetting;
|
||||
const aiEnabled = isAiEnabled(frameworkProvider);
|
||||
|
||||
const timestamp = i18nTime(status.startTime, {
|
||||
absolute: {
|
||||
@@ -89,6 +93,19 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
model.props.sourceId = blobId;
|
||||
model.props.embed = true;
|
||||
|
||||
const meetingSettingsService = frameworkProvider.get(
|
||||
MeetingSettingsService
|
||||
);
|
||||
|
||||
if (
|
||||
!meetingSettingsService.settings.autoTranscription ||
|
||||
!aiEnabled
|
||||
) {
|
||||
// auto transcription is disabled,
|
||||
// so we don't need to transcribe the recording by default
|
||||
return;
|
||||
}
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
@@ -100,8 +117,23 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
audioAttachment?.obj.transcribe().catch(err => {
|
||||
logger.error('Failed to transcribe recording', err);
|
||||
});
|
||||
} else {
|
||||
throw new Error('No attachment model found');
|
||||
}
|
||||
})().catch(console.error);
|
||||
})()
|
||||
.then(async () => {
|
||||
await apis?.recording.handleBlockCreationSuccess(status.id);
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Failed to transcribe recording', error);
|
||||
return apis?.recording.handleBlockCreationFailed(
|
||||
status.id,
|
||||
error
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('unknown error', error);
|
||||
});
|
||||
},
|
||||
};
|
||||
const page = docsService.createDoc({ docProps, primaryMode: 'page' });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ServersService } from '@affine/core/modules/cloud';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { WorkspacesService } from '@affine/core/modules/workspace';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
@@ -21,3 +23,23 @@ export function getCurrentWorkspace(frameworkProvider: FrameworkProvider) {
|
||||
[Symbol.dispose]: dispose,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentServerService(frameworkProvider: FrameworkProvider) {
|
||||
const currentServerId = frameworkProvider
|
||||
.get(GlobalContextService)
|
||||
.globalContext.serverId.get();
|
||||
const serversService = frameworkProvider.get(ServersService);
|
||||
const serverRef = currentServerId
|
||||
? serversService.servers$.value.find(
|
||||
server => server.id === currentServerId
|
||||
)
|
||||
: null;
|
||||
return serverRef;
|
||||
}
|
||||
|
||||
export function isAiEnabled(frameworkProvider: FrameworkProvider) {
|
||||
const featureFlagService = frameworkProvider.get(FeatureFlagService);
|
||||
const serverService = getCurrentServerService(frameworkProvider);
|
||||
const aiConfig = serverService?.features$.value.copilot;
|
||||
return featureFlagService.flags.enable_ai.$ && aiConfig;
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { ArrayBufferTarget, Muxer } from 'webm-muxer';
|
||||
|
||||
/**
|
||||
* Encodes raw audio data to Opus in WebM container.
|
||||
*/
|
||||
export async function encodeRawBufferToOpus({
|
||||
filepath,
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
}: {
|
||||
filepath: string;
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
}): Promise<Uint8Array> {
|
||||
// Use streams to process audio data incrementally
|
||||
const response = await fetch(new URL(filepath, location.origin));
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
// Setup Opus encoder
|
||||
const encodedChunks: EncodedAudioChunk[] = [];
|
||||
const encoder = new AudioEncoder({
|
||||
output: chunk => {
|
||||
encodedChunks.push(chunk);
|
||||
},
|
||||
error: err => {
|
||||
throw new Error(`Encoding error: ${err}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Configure Opus encoder
|
||||
encoder.configure({
|
||||
codec: 'opus',
|
||||
sampleRate: sampleRate,
|
||||
numberOfChannels: numberOfChannels,
|
||||
bitrate: 128000,
|
||||
});
|
||||
|
||||
// Process the stream
|
||||
const reader = response.body.getReader();
|
||||
let offset = 0;
|
||||
const CHUNK_SIZE = numberOfChannels * 1024; // Process 1024 samples per channel at a time
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Convert the chunk to Float32Array
|
||||
const float32Data = new Float32Array(value.buffer);
|
||||
|
||||
// Process in smaller chunks to avoid large frames
|
||||
for (let i = 0; i < float32Data.length; i += CHUNK_SIZE) {
|
||||
const chunkSize = Math.min(CHUNK_SIZE, float32Data.length - i);
|
||||
const chunk = float32Data.subarray(i, i + chunkSize);
|
||||
|
||||
// Create and encode frame
|
||||
const frame = new AudioData({
|
||||
format: 'f32',
|
||||
sampleRate: sampleRate,
|
||||
numberOfFrames: chunk.length / numberOfChannels,
|
||||
numberOfChannels: numberOfChannels,
|
||||
timestamp: (offset * 1000000) / sampleRate, // timestamp in microseconds
|
||||
data: chunk,
|
||||
});
|
||||
|
||||
encoder.encode(frame);
|
||||
frame.close();
|
||||
|
||||
offset += chunk.length / numberOfChannels;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await encoder.flush();
|
||||
encoder.close();
|
||||
}
|
||||
|
||||
// Initialize WebM muxer
|
||||
const target = new ArrayBufferTarget();
|
||||
const muxer = new Muxer({
|
||||
target,
|
||||
audio: {
|
||||
codec: 'A_OPUS',
|
||||
sampleRate: sampleRate,
|
||||
numberOfChannels: numberOfChannels,
|
||||
},
|
||||
});
|
||||
|
||||
// Add all chunks to the muxer
|
||||
for (const chunk of encodedChunks) {
|
||||
muxer.addAudioChunk(chunk, {});
|
||||
}
|
||||
|
||||
// Finalize and get WebM container
|
||||
muxer.finalize();
|
||||
const { buffer: webmBuffer } = target;
|
||||
|
||||
return new Uint8Array(webmBuffer);
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { appIconMap } from '@affine/core/utils';
|
||||
import { encodeRawBufferToOpus } from '@affine/core/utils/webm-encoding';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { encodeRawBufferToOpus } from './encode';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type Status = {
|
||||
id: number;
|
||||
status: 'new' | 'recording' | 'paused' | 'stopped' | 'ready';
|
||||
status:
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'create-block-success'
|
||||
| 'create-block-failed';
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
filepath?: string;
|
||||
};
|
||||
|
||||
export const useRecordingStatus = () => {
|
||||
@@ -23,12 +31,12 @@ export const useRecordingStatus = () => {
|
||||
// Get initial status
|
||||
apis?.recording
|
||||
.getCurrentRecording()
|
||||
.then(status => setStatus(status as Status))
|
||||
.then(status => setStatus(status satisfies Status | null))
|
||||
.catch(console.error);
|
||||
|
||||
// Subscribe to status changes
|
||||
const unsubscribe = events?.recording.onRecordingStatusChanged(status =>
|
||||
setStatus(status as Status)
|
||||
setStatus(status satisfies Status | null)
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -51,15 +59,24 @@ export function Recording() {
|
||||
}
|
||||
if (status.status === 'new') {
|
||||
return t['com.affine.recording.new']();
|
||||
} else if (status.status === 'ready') {
|
||||
return t['com.affine.recording.ready']();
|
||||
} else if (status.appName) {
|
||||
return t['com.affine.recording.recording']({
|
||||
appName: status.appName,
|
||||
});
|
||||
} else {
|
||||
return t['com.affine.recording.recording.unnamed']();
|
||||
} else if (status.status === 'create-block-success') {
|
||||
return t['com.affine.recording.success.prompt']();
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
return t['com.affine.recording.failed.prompt']();
|
||||
} else if (
|
||||
status.status === 'recording' ||
|
||||
status.status === 'ready' ||
|
||||
status.status === 'stopped'
|
||||
) {
|
||||
if (status.appName) {
|
||||
return t['com.affine.recording.recording']({
|
||||
appName: status.appName,
|
||||
});
|
||||
} else {
|
||||
return t['com.affine.recording.recording.unnamed']();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [status, t]);
|
||||
|
||||
const handleDismiss = useAsyncCallback(async () => {
|
||||
@@ -77,6 +94,7 @@ export function Recording() {
|
||||
let id: number | undefined;
|
||||
try {
|
||||
const result = await apis?.recording?.getCurrentRecording();
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
@@ -96,7 +114,7 @@ export function Recording() {
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 1000); // wait at least 1 second for better user experience
|
||||
}, 500); // wait at least 500ms for better user experience
|
||||
}),
|
||||
]);
|
||||
await apis?.recording.readyRecording(result.id, buffer);
|
||||
@@ -125,6 +143,13 @@ export function Recording() {
|
||||
await apis?.recording?.startRecording(status.appGroupId);
|
||||
}, [status]);
|
||||
|
||||
const handleOpenFile = useAsyncCallback(async () => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
await apis?.recording?.showSavedRecordings(status.filepath);
|
||||
}, [status]);
|
||||
|
||||
const controlsElement = useMemo(() => {
|
||||
if (!status) {
|
||||
return null;
|
||||
@@ -150,7 +175,7 @@ export function Recording() {
|
||||
{t['com.affine.recording.stop']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'stopped') {
|
||||
} else if (status.status === 'stopped' || status.status === 'ready') {
|
||||
return (
|
||||
<Button
|
||||
variant="error"
|
||||
@@ -159,15 +184,33 @@ export function Recording() {
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
} else if (status.status === 'ready') {
|
||||
} else if (status.status === 'create-block-success') {
|
||||
return (
|
||||
<Button variant="primary" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.ready']()}
|
||||
{t['com.affine.recording.success.button']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
return (
|
||||
<>
|
||||
<Button variant="plain" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.dismiss']()}
|
||||
</Button>
|
||||
<Button variant="error" onClick={handleOpenFile}>
|
||||
{t['com.affine.recording.failed.button']()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [handleDismiss, handleStartRecording, handleStopRecording, status, t]);
|
||||
}, [
|
||||
handleDismiss,
|
||||
handleOpenFile,
|
||||
handleStartRecording,
|
||||
handleStopRecording,
|
||||
status,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
|
||||
@@ -169,6 +169,10 @@ export default {
|
||||
],
|
||||
executableName: productName,
|
||||
asar: true,
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription:
|
||||
'Please allow access in order to capture audio from other apps by AFFiNE.',
|
||||
},
|
||||
},
|
||||
makers,
|
||||
plugins: [{ name: '@electron-forge/plugin-auto-unpack-natives', config: {} }],
|
||||
|
||||
@@ -39,7 +39,7 @@ export function createApplicationMenu() {
|
||||
label: `About ${app.getName()}`,
|
||||
click: async () => {
|
||||
await showMainWindow();
|
||||
applicationMenuSubjects.openAboutPageInSettingModal$.next();
|
||||
applicationMenuSubjects.openInSettingModal$.next('about');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
|
||||
@@ -17,9 +17,9 @@ export const applicationMenuEvents = {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
openAboutPageInSettingModal: (fn: () => void) => {
|
||||
const sub =
|
||||
applicationMenuSubjects.openAboutPageInSettingModal$.subscribe(fn);
|
||||
// todo: properly define the active tab type
|
||||
openInSettingModal: (fn: (activeTab: string) => void) => {
|
||||
const sub = applicationMenuSubjects.openInSettingModal$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
|
||||
@@ -3,5 +3,5 @@ import { Subject } from 'rxjs';
|
||||
export const applicationMenuSubjects = {
|
||||
newPageAction$: new Subject<'page' | 'edgeless'>(),
|
||||
openJournal$: new Subject<void>(),
|
||||
openAboutPageInSettingModal$: new Subject<void>(),
|
||||
openInSettingModal$: new Subject<string>(),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { registerEvents } from './events';
|
||||
import { registerHandlers } from './handlers';
|
||||
import { logger } from './logger';
|
||||
import { registerProtocol } from './protocol';
|
||||
import { setupRecording } from './recording';
|
||||
import { setupRecordingFeature } from './recording/feature';
|
||||
import { setupTrayState } from './tray';
|
||||
import { registerUpdater } from './updater';
|
||||
import { launch } from './windows-manager/launcher';
|
||||
@@ -89,18 +89,10 @@ app
|
||||
.then(launch)
|
||||
.then(createApplicationMenu)
|
||||
.then(registerUpdater)
|
||||
.then(setupRecordingFeature)
|
||||
.then(setupTrayState)
|
||||
.catch(e => console.error('Failed create window:', e));
|
||||
|
||||
if (isDev) {
|
||||
app
|
||||
.whenReady()
|
||||
.then(setupRecording)
|
||||
.then(setupTrayState)
|
||||
.catch(e => {
|
||||
logger.error('Failed setup recording or tray state:', e);
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.SENTRY_RELEASE) {
|
||||
// https://docs.sentry.io/platforms/javascript/guides/electron/
|
||||
Sentry.init({
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
/* oxlint-disable no-var-requires */
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
// Should not load @affine/native for unsupported platforms
|
||||
import type { ShareableContent } from '@affine/native';
|
||||
import { app, systemPreferences } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import { debounce } from 'lodash-es';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
groupBy,
|
||||
interval,
|
||||
mergeMap,
|
||||
Subject,
|
||||
throttleTime,
|
||||
} from 'rxjs';
|
||||
import { map, shareReplay } from 'rxjs/operators';
|
||||
|
||||
import { isMacOS, shallowEqual } from '../../shared/utils';
|
||||
import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
MeetingSettingsKey,
|
||||
MeetingSettingsSchema,
|
||||
} from '../shared-state-schema';
|
||||
import { globalStateStorage } from '../shared-storage/storage';
|
||||
import { getMainWindow } from '../windows-manager';
|
||||
import { popupManager } from '../windows-manager/popup';
|
||||
import { recordingStateMachine } from './state-machine';
|
||||
import type {
|
||||
AppGroupInfo,
|
||||
Recording,
|
||||
RecordingStatus,
|
||||
TappableAppInfo,
|
||||
} from './types';
|
||||
|
||||
const MAX_DURATION_FOR_TRANSCRIPTION = 1.5 * 60 * 60 * 1000; // 1.5 hours
|
||||
|
||||
export const MeetingsSettingsState = {
|
||||
$: globalStateStorage.watch<MeetingSettingsSchema>(MeetingSettingsKey).pipe(
|
||||
map(v => MeetingSettingsSchema.parse(v ?? {})),
|
||||
shareReplay(1)
|
||||
),
|
||||
|
||||
get value() {
|
||||
return MeetingSettingsSchema.parse(
|
||||
globalStateStorage.get(MeetingSettingsKey) ?? {}
|
||||
);
|
||||
},
|
||||
|
||||
set value(value: MeetingSettingsSchema) {
|
||||
globalStateStorage.set(MeetingSettingsKey, value);
|
||||
},
|
||||
};
|
||||
|
||||
const subscribers: Subscriber[] = [];
|
||||
|
||||
// recordings are saved in the app data directory
|
||||
// may need a way to clean up old recordings
|
||||
export const SAVED_RECORDINGS_DIR = path.join(
|
||||
app.getPath('sessionData'),
|
||||
'recordings'
|
||||
);
|
||||
|
||||
let shareableContent: ShareableContent | null = null;
|
||||
|
||||
function cleanup() {
|
||||
shareableContent = null;
|
||||
subscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeAppQuit(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
|
||||
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
|
||||
|
||||
export const updateApplicationsPing$ = new Subject<number>();
|
||||
|
||||
// recording id -> recording
|
||||
// recordings will be saved in memory before consumed and created as an audio block to user's doc
|
||||
const recordings = new Map<number, Recording>();
|
||||
|
||||
// there should be only one active recording at a time
|
||||
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
|
||||
export const recordingStatus$ = recordingStateMachine.status$;
|
||||
|
||||
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
|
||||
const groupProcess =
|
||||
shareableContent?.applicationWithProcessId(processGroupId);
|
||||
if (!groupProcess) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
processGroupId: processGroupId,
|
||||
apps: [], // leave it empty for now.
|
||||
name: groupProcess.name,
|
||||
bundleIdentifier: groupProcess.bundleIdentifier,
|
||||
// icon should be lazy loaded
|
||||
get icon() {
|
||||
try {
|
||||
return groupProcess.icon;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get icon for ${groupProcess.name}`, error);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
isRunning: false,
|
||||
};
|
||||
}
|
||||
|
||||
// pipe applications$ to appGroups$
|
||||
function setupAppGroups() {
|
||||
subscribers.push(
|
||||
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
|
||||
const appGroups: AppGroupInfo[] = [];
|
||||
apps.forEach(app => {
|
||||
let appGroup = appGroups.find(
|
||||
group => group.processGroupId === app.processGroupId
|
||||
);
|
||||
|
||||
if (!appGroup) {
|
||||
appGroup = createAppGroup(app.processGroupId);
|
||||
if (appGroup) {
|
||||
appGroups.push(appGroup);
|
||||
}
|
||||
}
|
||||
if (appGroup) {
|
||||
appGroup.apps.push(app);
|
||||
}
|
||||
});
|
||||
|
||||
appGroups.forEach(appGroup => {
|
||||
appGroup.isRunning = appGroup.apps.some(app => app.isRunning);
|
||||
});
|
||||
|
||||
appGroups$.next(appGroups);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function setupNewRunningAppGroup() {
|
||||
const appGroupRunningChanged$ = appGroups$.pipe(
|
||||
mergeMap(groups => groups),
|
||||
groupBy(group => group.processGroupId),
|
||||
mergeMap(groupStream$ =>
|
||||
groupStream$.pipe(
|
||||
distinctUntilChanged((prev, curr) => prev.isRunning === curr.isRunning)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
appGroups$.value.forEach(group => {
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
if (
|
||||
group.isRunning &&
|
||||
(!recordingStatus || recordingStatus.status === 'new')
|
||||
) {
|
||||
newRecording(group);
|
||||
}
|
||||
});
|
||||
|
||||
const debounceStartRecording = debounce((appGroup: AppGroupInfo) => {
|
||||
// check if the app is running again
|
||||
if (appGroup.isRunning) {
|
||||
startRecording(appGroup);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
subscribers.push(
|
||||
appGroupRunningChanged$.subscribe(currentGroup => {
|
||||
logger.info(
|
||||
'appGroupRunningChanged',
|
||||
currentGroup.bundleIdentifier,
|
||||
currentGroup.isRunning
|
||||
);
|
||||
|
||||
if (MeetingsSettingsState.value.recordingMode === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
|
||||
if (currentGroup.isRunning) {
|
||||
// when the app is running and there is no active recording popup
|
||||
// we should show a new recording popup
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.status === 'new' ||
|
||||
recordingStatus.status === 'create-block-success' ||
|
||||
recordingStatus.status === 'create-block-failed'
|
||||
) {
|
||||
if (MeetingsSettingsState.value.recordingMode === 'prompt') {
|
||||
newRecording(currentGroup);
|
||||
} else if (
|
||||
MeetingsSettingsState.value.recordingMode === 'auto-start'
|
||||
) {
|
||||
// there is a case that the watched app's running state changed rapidly
|
||||
// we will schedule the start recording to avoid that
|
||||
debounceStartRecording(currentGroup);
|
||||
} else {
|
||||
// do nothing, skip
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// when displaying in "new" state but the app is not running any more
|
||||
// we should remove the recording
|
||||
if (
|
||||
recordingStatus?.status === 'new' &&
|
||||
currentGroup.bundleIdentifier ===
|
||||
recordingStatus.appGroup?.bundleIdentifier
|
||||
) {
|
||||
removeRecording(recordingStatus.id);
|
||||
}
|
||||
|
||||
// if the recording is stopped and we are recording it,
|
||||
// we should stop the recording
|
||||
if (
|
||||
recordingStatus?.status === 'recording' &&
|
||||
recordingStatus.appGroup?.bundleIdentifier ===
|
||||
currentGroup.bundleIdentifier
|
||||
) {
|
||||
stopRecording(recordingStatus.id).catch(err => {
|
||||
logger.error('failed to stop recording', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function createRecording(status: RecordingStatus) {
|
||||
const bufferedFilePath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${status.appGroup?.bundleIdentifier ?? 'unknown'}-${status.id}-${status.startTime}.raw`
|
||||
);
|
||||
|
||||
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
|
||||
const file = fs.createWriteStream(bufferedFilePath);
|
||||
|
||||
function tapAudioSamples(err: Error | null, samples: Float32Array) {
|
||||
const recordingStatus = recordingStatus$.getValue();
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.id !== status.id ||
|
||||
recordingStatus.status === 'paused'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
logger.error('failed to get audio samples', err);
|
||||
} else {
|
||||
// Writing raw Float32Array samples directly to file
|
||||
// For stereo audio, samples are interleaved [L,R,L,R,...]
|
||||
file.write(Buffer.from(samples.buffer));
|
||||
}
|
||||
}
|
||||
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
|
||||
const stream = status.app
|
||||
? status.app.rawInstance.tapAudio(tapAudioSamples)
|
||||
: ShareableContent.tapGlobalAudio(null, tapAudioSamples);
|
||||
|
||||
const recording: Recording = {
|
||||
id: status.id,
|
||||
startTime: status.startTime,
|
||||
app: status.app,
|
||||
appGroup: status.appGroup,
|
||||
file,
|
||||
stream,
|
||||
};
|
||||
|
||||
return recording;
|
||||
}
|
||||
|
||||
export async function getRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
const rawFilePath = String(recording.file.path);
|
||||
return {
|
||||
id,
|
||||
appGroup: recording.appGroup,
|
||||
app: recording.app,
|
||||
startTime: recording.startTime,
|
||||
filepath: rawFilePath,
|
||||
sampleRate: recording.stream.sampleRate,
|
||||
numberOfChannels: recording.stream.channels,
|
||||
};
|
||||
}
|
||||
|
||||
// recording popup status
|
||||
// new: recording is started, popup is shown
|
||||
// recording: recording is started, popup is shown
|
||||
// stopped: recording is stopped, popup showing processing status
|
||||
// create-block-success: recording is ready, show "open app" button
|
||||
// create-block-failed: recording is failed, show "failed to save" button
|
||||
// null: hide popup
|
||||
function setupRecordingListeners() {
|
||||
subscribers.push(
|
||||
recordingStatus$
|
||||
.pipe(distinctUntilChanged(shallowEqual))
|
||||
.subscribe(status => {
|
||||
const popup = popupManager.get('recording');
|
||||
|
||||
if (status && !popup.showing) {
|
||||
popup.show().catch(err => {
|
||||
logger.error('failed to show recording popup', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (status?.status === 'recording') {
|
||||
let recording = recordings.get(status.id);
|
||||
// create a recording if not exists
|
||||
if (!recording) {
|
||||
recording = createRecording(status);
|
||||
recordings.set(status.id, recording);
|
||||
}
|
||||
} else if (status?.status === 'stopped') {
|
||||
const recording = recordings.get(status.id);
|
||||
if (recording) {
|
||||
recording.stream.stop();
|
||||
}
|
||||
} else if (
|
||||
status?.status === 'create-block-success' ||
|
||||
status?.status === 'create-block-failed'
|
||||
) {
|
||||
// show the popup for 10s
|
||||
setTimeout(() => {
|
||||
// check again if current status is still ready
|
||||
if (
|
||||
(recordingStatus$.value?.status === 'create-block-success' ||
|
||||
recordingStatus$.value?.status === 'create-block-failed') &&
|
||||
recordingStatus$.value.id === status.id
|
||||
) {
|
||||
popup.hide().catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
}, 10_000);
|
||||
} else if (!status) {
|
||||
// status is removed, we should hide the popup
|
||||
popupManager
|
||||
.get('recording')
|
||||
.hide()
|
||||
.catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getAllApps(): TappableAppInfo[] {
|
||||
if (!shareableContent) {
|
||||
return [];
|
||||
}
|
||||
const apps = shareableContent.applications().map(app => {
|
||||
try {
|
||||
return {
|
||||
rawInstance: app,
|
||||
processId: app.processId,
|
||||
processGroupId: app.processGroupId,
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
name: app.name,
|
||||
isRunning: app.isRunning,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('failed to get app info', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredApps = apps.filter(
|
||||
(v): v is TappableAppInfo =>
|
||||
v !== null &&
|
||||
!v.bundleIdentifier.startsWith('com.apple') &&
|
||||
!v.bundleIdentifier.startsWith('pro.affine') &&
|
||||
v.processId !== process.pid
|
||||
);
|
||||
return filteredApps;
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
function setupMediaListeners() {
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
applications$.next(getAllApps());
|
||||
subscribers.push(
|
||||
interval(3000).subscribe(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
ShareableContent.onApplicationListChanged(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
updateApplicationsPing$
|
||||
.pipe(distinctUntilChanged(), throttleTime(3000))
|
||||
.subscribe(() => {
|
||||
applications$.next(getAllApps());
|
||||
})
|
||||
);
|
||||
|
||||
let appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
subscribers.push(
|
||||
applications$.subscribe(apps => {
|
||||
appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
const _appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
apps.forEach(app => {
|
||||
try {
|
||||
const tappableApp = app.rawInstance;
|
||||
_appStateSubscribers.push(
|
||||
ShareableContent.onAppStateChanged(tappableApp, () => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to convert app ${app.name} to TappableApplication`,
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appStateSubscribers = _appStateSubscribers;
|
||||
return () => {
|
||||
_appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// will be called when the app is ready or when the user has enabled the recording feature in settings
|
||||
export function setupRecordingFeature() {
|
||||
if (!MeetingsSettingsState.value.enabled || !checkRecordingAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
if (!shareableContent) {
|
||||
shareableContent = new ShareableContent();
|
||||
setupMediaListeners();
|
||||
}
|
||||
// reset all states
|
||||
recordingStatus$.next(null);
|
||||
setupAppGroups();
|
||||
setupNewRunningAppGroup();
|
||||
setupRecordingListeners();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('failed to setup recording feature', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function disableRecordingFeature() {
|
||||
recordingStatus$.next(null);
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function normalizeAppGroupInfo(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): AppGroupInfo | undefined {
|
||||
return typeof appGroup === 'number'
|
||||
? appGroups$.value.find(group => group.processGroupId === appGroup)
|
||||
: appGroup;
|
||||
}
|
||||
|
||||
export function newRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
return recordingStateMachine.dispatch({
|
||||
type: 'NEW_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
}
|
||||
|
||||
export function startRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
const state = recordingStateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
|
||||
// set a timeout to stop the recording after MAX_DURATION_FOR_TRANSCRIPTION
|
||||
setTimeout(() => {
|
||||
if (
|
||||
state?.status === 'recording' &&
|
||||
state.id === recordingStatus$.value?.id
|
||||
) {
|
||||
stopRecording(state.id).catch(err => {
|
||||
logger.error('failed to stop recording', err);
|
||||
});
|
||||
}
|
||||
}, MAX_DURATION_FOR_TRANSCRIPTION);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function pauseRecording(id: number) {
|
||||
return recordingStateMachine.dispatch({ type: 'PAUSE_RECORDING', id });
|
||||
}
|
||||
|
||||
export function resumeRecording(id: number) {
|
||||
return recordingStateMachine.dispatch({ type: 'RESUME_RECORDING', id });
|
||||
}
|
||||
|
||||
export async function stopRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recording.file.path) {
|
||||
logger.error(`Recording ${id} has no file path`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { file } = recording;
|
||||
file.end();
|
||||
|
||||
// Wait for file to finish writing
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
file.on('finish', () => {
|
||||
// check if the file is empty
|
||||
const stats = fs.statSync(file.path);
|
||||
if (stats.size === 0) {
|
||||
logger.error(`Recording ${id} is empty`);
|
||||
reject(new Error('Recording is empty'));
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const recordingStatus = recordingStateMachine.dispatch({
|
||||
type: 'STOP_RECORDING',
|
||||
id,
|
||||
filepath: String(recording.file.path),
|
||||
sampleRate: recording.stream.sampleRate,
|
||||
numberOfChannels: recording.stream.channels,
|
||||
});
|
||||
|
||||
if (!recordingStatus) {
|
||||
logger.error('No recording status to stop');
|
||||
return;
|
||||
}
|
||||
return serializeRecordingStatus(recordingStatus);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Failed to stop recording', error);
|
||||
const recordingStatus = recordingStateMachine.dispatch({
|
||||
type: 'CREATE_BLOCK_FAILED',
|
||||
id,
|
||||
error: error instanceof Error ? error : undefined,
|
||||
});
|
||||
if (!recordingStatus) {
|
||||
logger.error('No recording status to stop');
|
||||
return;
|
||||
}
|
||||
return serializeRecordingStatus(recordingStatus);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readyRecording(id: number, buffer: Buffer) {
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
const recording = recordings.get(id);
|
||||
if (!recordingStatus || recordingStatus.id !== id || !recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filepath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.webm`
|
||||
);
|
||||
|
||||
await fs.writeFile(filepath, buffer);
|
||||
|
||||
// Update the status through the state machine
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'SAVE_RECORDING',
|
||||
id,
|
||||
filepath,
|
||||
});
|
||||
|
||||
// bring up the window
|
||||
getMainWindow()
|
||||
.then(mainWindow => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('failed to bring up the window', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleBlockCreationSuccess(id: number) {
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'CREATE_BLOCK_SUCCESS',
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleBlockCreationFailed(id: number, error?: Error) {
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'CREATE_BLOCK_FAILED',
|
||||
id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
export function removeRecording(id: number) {
|
||||
recordings.delete(id);
|
||||
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
|
||||
}
|
||||
|
||||
export interface SerializedRecordingStatus {
|
||||
id: number;
|
||||
status: RecordingStatus['status'];
|
||||
appName?: string;
|
||||
// if there is no app group, it means the recording is for system audio
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
startTime: number;
|
||||
filepath?: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
}
|
||||
|
||||
export function serializeRecordingStatus(
|
||||
status: RecordingStatus
|
||||
): SerializedRecordingStatus {
|
||||
return {
|
||||
id: status.id,
|
||||
status: status.status,
|
||||
appName: status.appGroup?.name,
|
||||
appGroupId: status.appGroup?.processGroupId,
|
||||
icon: status.appGroup?.icon,
|
||||
startTime: status.startTime,
|
||||
filepath: status.filepath,
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
};
|
||||
}
|
||||
|
||||
export const getMacOSVersion = () => {
|
||||
try {
|
||||
const stdout = execSync('sw_vers -productVersion').toString();
|
||||
const [major, minor, patch] = stdout.trim().split('.').map(Number);
|
||||
return { major, minor, patch };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get MacOS version', error);
|
||||
return { major: 0, minor: 0, patch: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
// check if the system is MacOS and the version is >= 14.2
|
||||
export const checkRecordingAvailable = () => {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
}
|
||||
const version = getMacOSVersion();
|
||||
return (version.major === 14 && version.minor >= 2) || version.major > 14;
|
||||
};
|
||||
|
||||
export const checkScreenRecordingPermission = () => {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
}
|
||||
return systemPreferences.getMediaAccessStatus('screen') === 'granted';
|
||||
};
|
||||
@@ -1,546 +1,32 @@
|
||||
// eslint-disable no-var-requires
|
||||
|
||||
// Should not load @affine/native for unsupported platforms
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { ShareableContent } from '@affine/native';
|
||||
import { app } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
groupBy,
|
||||
interval,
|
||||
mergeMap,
|
||||
Subject,
|
||||
throttleTime,
|
||||
} from 'rxjs';
|
||||
import { shell } from 'electron';
|
||||
|
||||
import { isMacOS, shallowEqual } from '../../shared/utils';
|
||||
import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { getMainWindow } from '../windows-manager';
|
||||
import { popupManager } from '../windows-manager/popup';
|
||||
import { recordingStateMachine } from './state-machine';
|
||||
import type {
|
||||
AppGroupInfo,
|
||||
Recording,
|
||||
RecordingStatus,
|
||||
TappableAppInfo,
|
||||
} from './types';
|
||||
|
||||
const subscribers: Subscriber[] = [];
|
||||
|
||||
// adhoc recordings are saved in the temp directory
|
||||
const SAVED_RECORDINGS_DIR = path.join(
|
||||
app.getPath('temp'),
|
||||
'affine-recordings'
|
||||
);
|
||||
|
||||
beforeAppQuit(() => {
|
||||
subscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let shareableContent: ShareableContent | null = null;
|
||||
|
||||
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
|
||||
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
|
||||
|
||||
export const updateApplicationsPing$ = new Subject<number>();
|
||||
|
||||
// recording id -> recording
|
||||
// recordings will be saved in memory before consumed and created as an audio block to user's doc
|
||||
const recordings = new Map<number, Recording>();
|
||||
|
||||
// there should be only one active recording at a time
|
||||
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
|
||||
export const recordingStatus$ = recordingStateMachine.status$;
|
||||
|
||||
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
|
||||
const groupProcess =
|
||||
shareableContent?.applicationWithProcessId(processGroupId);
|
||||
if (!groupProcess) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
processGroupId: processGroupId,
|
||||
apps: [], // leave it empty for now.
|
||||
name: groupProcess.name,
|
||||
bundleIdentifier: groupProcess.bundleIdentifier,
|
||||
// icon should be lazy loaded
|
||||
get icon() {
|
||||
try {
|
||||
return groupProcess.icon;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get icon for ${groupProcess.name}`, error);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
isRunning: false,
|
||||
};
|
||||
}
|
||||
|
||||
// pipe applications$ to appGroups$
|
||||
function setupAppGroups() {
|
||||
subscribers.push(
|
||||
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
|
||||
const appGroups: AppGroupInfo[] = [];
|
||||
apps.forEach(app => {
|
||||
let appGroup = appGroups.find(
|
||||
group => group.processGroupId === app.processGroupId
|
||||
);
|
||||
|
||||
if (!appGroup) {
|
||||
appGroup = createAppGroup(app.processGroupId);
|
||||
if (appGroup) {
|
||||
appGroups.push(appGroup);
|
||||
}
|
||||
}
|
||||
if (appGroup) {
|
||||
appGroup.apps.push(app);
|
||||
}
|
||||
});
|
||||
|
||||
appGroups.forEach(appGroup => {
|
||||
appGroup.isRunning = appGroup.apps.some(app => app.isRunning);
|
||||
});
|
||||
|
||||
appGroups$.next(appGroups);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function setupNewRunningAppGroup() {
|
||||
const appGroupRunningChanged$ = appGroups$.pipe(
|
||||
mergeMap(groups => groups),
|
||||
groupBy(group => group.processGroupId),
|
||||
mergeMap(groupStream$ =>
|
||||
groupStream$.pipe(
|
||||
distinctUntilChanged((prev, curr) => prev.isRunning === curr.isRunning)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
appGroups$.value.forEach(group => {
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
if (
|
||||
group.isRunning &&
|
||||
(!recordingStatus || recordingStatus.status === 'new')
|
||||
) {
|
||||
newRecording(group);
|
||||
}
|
||||
});
|
||||
|
||||
subscribers.push(
|
||||
appGroupRunningChanged$.subscribe(currentGroup => {
|
||||
logger.info(
|
||||
'appGroupRunningChanged',
|
||||
currentGroup.bundleIdentifier,
|
||||
currentGroup.isRunning
|
||||
);
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
|
||||
if (currentGroup.isRunning) {
|
||||
// when the app is running and there is no active recording popup
|
||||
// we should show a new recording popup
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.status === 'new' ||
|
||||
recordingStatus.status === 'ready'
|
||||
) {
|
||||
newRecording(currentGroup);
|
||||
}
|
||||
} else {
|
||||
// when displaying in "new" state but the app is not running any more
|
||||
// we should remove the recording
|
||||
if (
|
||||
recordingStatus?.status === 'new' &&
|
||||
currentGroup.bundleIdentifier ===
|
||||
recordingStatus.appGroup?.bundleIdentifier
|
||||
) {
|
||||
removeRecording(recordingStatus.id);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function createRecording(status: RecordingStatus) {
|
||||
const bufferedFilePath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${status.appGroup?.bundleIdentifier ?? 'unknown'}-${status.id}-${status.startTime}.raw`
|
||||
);
|
||||
|
||||
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
|
||||
const file = fs.createWriteStream(bufferedFilePath);
|
||||
|
||||
function tapAudioSamples(err: Error | null, samples: Float32Array) {
|
||||
const recordingStatus = recordingStatus$.getValue();
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.id !== status.id ||
|
||||
recordingStatus.status === 'paused'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
logger.error('failed to get audio samples', err);
|
||||
} else {
|
||||
// Writing raw Float32Array samples directly to file
|
||||
// For stereo audio, samples are interleaved [L,R,L,R,...]
|
||||
file.write(Buffer.from(samples.buffer));
|
||||
}
|
||||
}
|
||||
|
||||
const stream = status.app
|
||||
? status.app.rawInstance.tapAudio(tapAudioSamples)
|
||||
: ShareableContent.tapGlobalAudio(null, tapAudioSamples);
|
||||
|
||||
const recording: Recording = {
|
||||
id: status.id,
|
||||
startTime: status.startTime,
|
||||
app: status.app,
|
||||
appGroup: status.appGroup,
|
||||
file,
|
||||
stream,
|
||||
};
|
||||
|
||||
return recording;
|
||||
}
|
||||
|
||||
export async function getRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
const rawFilePath = String(recording.file.path);
|
||||
return {
|
||||
id,
|
||||
appGroup: recording.appGroup,
|
||||
app: recording.app,
|
||||
startTime: recording.startTime,
|
||||
filepath: rawFilePath,
|
||||
sampleRate: recording.stream.sampleRate,
|
||||
numberOfChannels: recording.stream.channels,
|
||||
};
|
||||
}
|
||||
|
||||
// recording popup status
|
||||
// new: recording is started, popup is shown
|
||||
// recording: recording is started, popup is shown
|
||||
// stopped: recording is stopped, popup showing processing status
|
||||
// ready: recording is ready, show "open app" button
|
||||
// null: hide popup
|
||||
function setupRecordingListeners() {
|
||||
subscribers.push(
|
||||
recordingStatus$
|
||||
.pipe(distinctUntilChanged(shallowEqual))
|
||||
.subscribe(status => {
|
||||
const popup = popupManager.get('recording');
|
||||
|
||||
if (status && !popup.showing) {
|
||||
popup.show().catch(err => {
|
||||
logger.error('failed to show recording popup', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (status?.status === 'recording') {
|
||||
let recording = recordings.get(status.id);
|
||||
// create a recording if not exists
|
||||
if (!recording) {
|
||||
recording = createRecording(status);
|
||||
recordings.set(status.id, recording);
|
||||
}
|
||||
} else if (status?.status === 'stopped') {
|
||||
const recording = recordings.get(status.id);
|
||||
if (recording) {
|
||||
recording.stream.stop();
|
||||
}
|
||||
} else if (status?.status === 'ready') {
|
||||
// show the popup for 10s
|
||||
setTimeout(() => {
|
||||
// check again if current status is still ready
|
||||
if (
|
||||
recordingStatus$.value?.status === 'ready' &&
|
||||
recordingStatus$.value.id === status.id
|
||||
) {
|
||||
popup.hide().catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
}, 10_000);
|
||||
} else if (!status) {
|
||||
// status is removed, we should hide the popup
|
||||
popupManager
|
||||
.get('recording')
|
||||
.hide()
|
||||
.catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getAllApps(): TappableAppInfo[] {
|
||||
if (!shareableContent) {
|
||||
return [];
|
||||
}
|
||||
const apps = shareableContent.applications().map(app => {
|
||||
try {
|
||||
return {
|
||||
rawInstance: app,
|
||||
processId: app.processId,
|
||||
processGroupId: app.processGroupId,
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
name: app.name,
|
||||
isRunning: app.isRunning,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('failed to get app info', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredApps = apps.filter(
|
||||
(v): v is TappableAppInfo =>
|
||||
v !== null &&
|
||||
!v.bundleIdentifier.startsWith('com.apple') &&
|
||||
v.processId !== process.pid
|
||||
);
|
||||
return filteredApps;
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
function setupMediaListeners() {
|
||||
applications$.next(getAllApps());
|
||||
subscribers.push(
|
||||
interval(3000).subscribe(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
ShareableContent.onApplicationListChanged(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
updateApplicationsPing$
|
||||
.pipe(distinctUntilChanged(), throttleTime(3000))
|
||||
.subscribe(() => {
|
||||
applications$.next(getAllApps());
|
||||
})
|
||||
);
|
||||
|
||||
let appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
subscribers.push(
|
||||
applications$.subscribe(apps => {
|
||||
appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
const _appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
apps.forEach(app => {
|
||||
try {
|
||||
const tappableApp = app.rawInstance;
|
||||
_appStateSubscribers.push(
|
||||
ShareableContent.onAppStateChanged(tappableApp, () => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to convert app ${app.name} to TappableApplication`,
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
appStateSubscribers = _appStateSubscribers;
|
||||
return () => {
|
||||
_appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function setupRecording() {
|
||||
if (!isMacOS()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shareableContent) {
|
||||
try {
|
||||
shareableContent = new ShareableContent();
|
||||
setupMediaListeners();
|
||||
} catch (error) {
|
||||
logger.error('failed to get shareable content', error);
|
||||
}
|
||||
}
|
||||
setupAppGroups();
|
||||
setupNewRunningAppGroup();
|
||||
setupRecordingListeners();
|
||||
}
|
||||
|
||||
function normalizeAppGroupInfo(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): AppGroupInfo | undefined {
|
||||
return typeof appGroup === 'number'
|
||||
? appGroups$.value.find(group => group.processGroupId === appGroup)
|
||||
: appGroup;
|
||||
}
|
||||
|
||||
export function newRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
if (!shareableContent) {
|
||||
return null; // likely called on unsupported platform
|
||||
}
|
||||
|
||||
return recordingStateMachine.dispatch({
|
||||
type: 'NEW_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
}
|
||||
|
||||
export function startRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
return recordingStateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
}
|
||||
|
||||
export function pauseRecording(id: number) {
|
||||
return recordingStateMachine.dispatch({ type: 'PAUSE_RECORDING', id });
|
||||
}
|
||||
|
||||
export function resumeRecording(id: number) {
|
||||
return recordingStateMachine.dispatch({ type: 'RESUME_RECORDING', id });
|
||||
}
|
||||
|
||||
export async function stopRecording(id: number) {
|
||||
const recording = recordings.get(id);
|
||||
if (!recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recording.file.path) {
|
||||
logger.error(`Recording ${id} has no file path`);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingStatus = recordingStateMachine.dispatch({
|
||||
type: 'STOP_RECORDING',
|
||||
id,
|
||||
filepath: String(recording.file.path),
|
||||
sampleRate: recording.stream.sampleRate,
|
||||
numberOfChannels: recording.stream.channels,
|
||||
});
|
||||
|
||||
if (!recordingStatus) {
|
||||
logger.error('No recording status to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
const { file } = recording;
|
||||
file.end();
|
||||
|
||||
// Wait for file to finish writing
|
||||
await new Promise<void>(resolve => {
|
||||
file.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return serializeRecordingStatus(recordingStatus);
|
||||
}
|
||||
|
||||
export async function readyRecording(id: number, buffer: Buffer) {
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
const recording = recordings.get(id);
|
||||
if (!recordingStatus || recordingStatus.id !== id || !recording) {
|
||||
logger.error(`Recording ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filepath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.webm`
|
||||
);
|
||||
|
||||
await fs.writeFile(filepath, buffer);
|
||||
|
||||
// Update the status through the state machine
|
||||
recordingStateMachine.dispatch({
|
||||
type: 'SAVE_RECORDING',
|
||||
id,
|
||||
filepath,
|
||||
});
|
||||
|
||||
// bring up the window
|
||||
getMainWindow()
|
||||
.then(mainWindow => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('failed to bring up the window', err);
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecording(id: number) {
|
||||
recordings.delete(id);
|
||||
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
|
||||
}
|
||||
|
||||
export interface SerializedRecordingStatus {
|
||||
id: number;
|
||||
status: RecordingStatus['status'];
|
||||
appName?: string;
|
||||
// if there is no app group, it means the recording is for system audio
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
startTime: number;
|
||||
filepath?: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
}
|
||||
|
||||
function serializeRecordingStatus(
|
||||
status: RecordingStatus
|
||||
): SerializedRecordingStatus {
|
||||
return {
|
||||
id: status.id,
|
||||
status: status.status,
|
||||
appName: status.appGroup?.name,
|
||||
appGroupId: status.appGroup?.processGroupId,
|
||||
icon: status.appGroup?.icon,
|
||||
startTime: status.startTime,
|
||||
filepath: status.filepath,
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
};
|
||||
}
|
||||
import {
|
||||
checkRecordingAvailable,
|
||||
checkScreenRecordingPermission,
|
||||
disableRecordingFeature,
|
||||
getRecording,
|
||||
handleBlockCreationFailed,
|
||||
handleBlockCreationSuccess,
|
||||
pauseRecording,
|
||||
readyRecording,
|
||||
recordingStatus$,
|
||||
removeRecording,
|
||||
SAVED_RECORDINGS_DIR,
|
||||
type SerializedRecordingStatus,
|
||||
serializeRecordingStatus,
|
||||
setupRecordingFeature,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
} from './feature';
|
||||
import type { AppGroupInfo } from './types';
|
||||
|
||||
export const recordingHandlers = {
|
||||
getRecording: async (_, id: number) => {
|
||||
@@ -565,9 +51,47 @@ export const recordingHandlers = {
|
||||
readyRecording: async (_, id: number, buffer: Uint8Array) => {
|
||||
return readyRecording(id, Buffer.from(buffer));
|
||||
},
|
||||
handleBlockCreationSuccess: async (_, id: number) => {
|
||||
return handleBlockCreationSuccess(id);
|
||||
},
|
||||
handleBlockCreationFailed: async (_, id: number, error?: Error) => {
|
||||
return handleBlockCreationFailed(id, error);
|
||||
},
|
||||
removeRecording: async (_, id: number) => {
|
||||
return removeRecording(id);
|
||||
},
|
||||
checkRecordingAvailable: async () => {
|
||||
return checkRecordingAvailable();
|
||||
},
|
||||
setupRecordingFeature: async () => {
|
||||
return setupRecordingFeature();
|
||||
},
|
||||
disableRecordingFeature: async () => {
|
||||
return disableRecordingFeature();
|
||||
},
|
||||
checkScreenRecordingPermission: async () => {
|
||||
return checkScreenRecordingPermission();
|
||||
},
|
||||
showScreenRecordingPermissionSetting: async () => {
|
||||
if (isMacOS()) {
|
||||
return shell.openExternal(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'
|
||||
);
|
||||
}
|
||||
// this only available on MacOS
|
||||
return false;
|
||||
},
|
||||
showSavedRecordings: async (_, subpath?: string) => {
|
||||
const normalizedDir = path.normalize(
|
||||
path.join(SAVED_RECORDINGS_DIR, subpath ?? '')
|
||||
);
|
||||
const normalizedBase = path.normalize(SAVED_RECORDINGS_DIR);
|
||||
|
||||
if (!normalizedDir.startsWith(normalizedBase)) {
|
||||
throw new Error('Invalid directory');
|
||||
}
|
||||
return shell.showItemInFolder(normalizedDir);
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
export const recordingEvents = {
|
||||
|
||||
@@ -4,17 +4,6 @@ import { shallowEqual } from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import type { AppGroupInfo, RecordingStatus } from './types';
|
||||
|
||||
/**
|
||||
* Possible states for a recording
|
||||
*/
|
||||
export type RecordingState =
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'inactive';
|
||||
|
||||
/**
|
||||
* Recording state machine events
|
||||
*/
|
||||
@@ -35,6 +24,15 @@ export type RecordingEvent =
|
||||
id: number;
|
||||
filepath: string;
|
||||
}
|
||||
| {
|
||||
type: 'CREATE_BLOCK_FAILED';
|
||||
id: number;
|
||||
error?: Error;
|
||||
}
|
||||
| {
|
||||
type: 'CREATE_BLOCK_SUCCESS';
|
||||
id: number;
|
||||
}
|
||||
| { type: 'REMOVE_RECORDING'; id: number };
|
||||
|
||||
/**
|
||||
@@ -93,6 +91,12 @@ export class RecordingStateMachine {
|
||||
case 'SAVE_RECORDING':
|
||||
newStatus = this.handleSaveRecording(event.id, event.filepath);
|
||||
break;
|
||||
case 'CREATE_BLOCK_SUCCESS':
|
||||
newStatus = this.handleCreateBlockSuccess(event.id);
|
||||
break;
|
||||
case 'CREATE_BLOCK_FAILED':
|
||||
newStatus = this.handleCreateBlockFailed(event.id, event.error);
|
||||
break;
|
||||
case 'REMOVE_RECORDING':
|
||||
this.handleRemoveRecording(event.id);
|
||||
newStatus = currentStatus?.id === event.id ? null : currentStatus;
|
||||
@@ -255,6 +259,47 @@ export class RecordingStateMachine {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the CREATE_BLOCK_SUCCESS event
|
||||
*/
|
||||
private handleCreateBlockSuccess(id: number): RecordingStatus | null {
|
||||
const currentStatus = this.recordingStatus$.value;
|
||||
|
||||
if (!currentStatus || currentStatus.id !== id) {
|
||||
logger.error(`Recording ${id} not found for create-block-success`);
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentStatus,
|
||||
status: 'create-block-success',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the CREATE_BLOCK_FAILED event
|
||||
*/
|
||||
private handleCreateBlockFailed(
|
||||
id: number,
|
||||
error?: Error
|
||||
): RecordingStatus | null {
|
||||
const currentStatus = this.recordingStatus$.value;
|
||||
|
||||
if (!currentStatus || currentStatus.id !== id) {
|
||||
logger.error(`Recording ${id} not found for create-block-failed`);
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
logger.error(`Recording ${id} create block failed:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
...currentStatus,
|
||||
status: 'create-block-failed',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the REMOVE_RECORDING event
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,16 @@ export interface RecordingStatus {
|
||||
// paused: the recording is paused
|
||||
// stopped: the recording is stopped (processing audio file for use in the editor)
|
||||
// ready: the recording is ready to be used
|
||||
status: 'new' | 'recording' | 'paused' | 'stopped' | 'ready';
|
||||
// create-block-success: the recording is successfully created as a block
|
||||
// create-block-failed: creating block failed
|
||||
status:
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'create-block-success'
|
||||
| 'create-block-failed';
|
||||
app?: TappableAppInfo;
|
||||
appGroup?: AppGroupInfo;
|
||||
startTime: number; // 0 means not started yet
|
||||
|
||||
@@ -53,3 +53,21 @@ export const SpellCheckStateSchema = z.object({
|
||||
export const SpellCheckStateKey = 'spellCheckState' as const;
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>;
|
||||
|
||||
export const MeetingSettingsKey = 'meetingSettings' as const;
|
||||
export const MeetingSettingsSchema = z.object({
|
||||
// global meeting feature control
|
||||
enabled: z.boolean().default(false),
|
||||
|
||||
// when recording is saved, where to create the recording block
|
||||
recordingSavingMode: z.enum(['new-doc', 'journal-today']).default('new-doc'),
|
||||
|
||||
// whether to enable auto transcription for new meeting recordings
|
||||
autoTranscription: z.boolean().default(true),
|
||||
|
||||
// recording reactions to new meeting events
|
||||
recordingMode: z.enum(['none', 'prompt', 'auto-start']).default('prompt'),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export type MeetingSettingsSchema = z.infer<typeof MeetingSettingsSchema>;
|
||||
|
||||
@@ -14,11 +14,14 @@ import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
appGroups$,
|
||||
checkRecordingAvailable,
|
||||
checkScreenRecordingPermission,
|
||||
MeetingsSettingsState,
|
||||
recordingStatus$,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
updateApplicationsPing$,
|
||||
} from '../recording';
|
||||
} from '../recording/feature';
|
||||
import { getMainWindow } from '../windows-manager';
|
||||
import { icons } from './icons';
|
||||
|
||||
@@ -61,6 +64,10 @@ function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
|
||||
}
|
||||
if (nativeIcon) {
|
||||
nativeIcon = nativeIcon.resize({ width: 20, height: 20 });
|
||||
// string icon should be template image
|
||||
if (typeof icon === 'string') {
|
||||
nativeIcon.setTemplateImage(true);
|
||||
}
|
||||
}
|
||||
const submenuConfig = submenu ? buildMenuConfig(submenu) : undefined;
|
||||
menuConfig.push({
|
||||
@@ -125,30 +132,37 @@ class TrayState {
|
||||
};
|
||||
}
|
||||
|
||||
getRecordingMenuProvider(): TrayMenuProvider {
|
||||
const appGroups = appGroups$.value;
|
||||
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
|
||||
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
|
||||
getRecordingMenuProvider(): TrayMenuProvider | null {
|
||||
if (
|
||||
!recordingStatus ||
|
||||
(recordingStatus?.status !== 'paused' &&
|
||||
recordingStatus?.status !== 'recording')
|
||||
!checkRecordingAvailable() ||
|
||||
!checkScreenRecordingPermission() ||
|
||||
!MeetingsSettingsState.value.enabled
|
||||
) {
|
||||
const appMenuItems = runningAppGroups.map(appGroup => ({
|
||||
label: appGroup.name,
|
||||
icon: appGroup.icon || undefined,
|
||||
click: () => {
|
||||
logger.info(
|
||||
`User action: Start Recording Meeting (${appGroup.name})`
|
||||
);
|
||||
startRecording(appGroup);
|
||||
},
|
||||
}));
|
||||
return {
|
||||
key: 'recording',
|
||||
getConfig: () => [
|
||||
return null;
|
||||
}
|
||||
|
||||
const getConfig = () => {
|
||||
const appGroups = appGroups$.value;
|
||||
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
|
||||
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
|
||||
if (
|
||||
!recordingStatus ||
|
||||
(recordingStatus?.status !== 'paused' &&
|
||||
recordingStatus?.status !== 'recording')
|
||||
) {
|
||||
const appMenuItems = runningAppGroups.map(appGroup => ({
|
||||
label: appGroup.name,
|
||||
icon: appGroup.icon || undefined,
|
||||
click: () => {
|
||||
logger.info(
|
||||
`User action: Start Recording Meeting (${appGroup.name})`
|
||||
);
|
||||
startRecording(appGroup);
|
||||
},
|
||||
}));
|
||||
return [
|
||||
{
|
||||
label: 'Start Recording Meeting',
|
||||
icon: icons.record,
|
||||
@@ -167,18 +181,22 @@ class TrayState {
|
||||
],
|
||||
},
|
||||
...appMenuItems,
|
||||
],
|
||||
};
|
||||
}
|
||||
{
|
||||
label: `Meetings Settings...`,
|
||||
click: async () => {
|
||||
showMainWindow();
|
||||
applicationMenuSubjects.openInSettingModal$.next('meetings');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const recordingLabel = recordingStatus.appGroup?.name
|
||||
? `Recording (${recordingStatus.appGroup?.name})`
|
||||
: 'Recording';
|
||||
const recordingLabel = recordingStatus.appGroup?.name
|
||||
? `Recording (${recordingStatus.appGroup?.name})`
|
||||
: 'Recording';
|
||||
|
||||
// recording is either started or paused
|
||||
return {
|
||||
key: 'recording',
|
||||
getConfig: () => [
|
||||
// recording is either started or paused
|
||||
return [
|
||||
{
|
||||
label: recordingLabel,
|
||||
icon: icons.recording,
|
||||
@@ -193,7 +211,12 @@ class TrayState {
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
return {
|
||||
key: 'recording',
|
||||
getConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,6 +237,13 @@ class TrayState {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: `About ${app.getName()}`,
|
||||
click: () => {
|
||||
showMainWindow();
|
||||
applicationMenuSubjects.openInSettingModal$.next('about');
|
||||
},
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
label: 'Quit AFFiNE Completely...',
|
||||
@@ -267,7 +297,7 @@ class TrayState {
|
||||
|
||||
const providers = [
|
||||
this.getPrimaryMenuProvider(),
|
||||
isMacOS() ? this.getRecordingMenuProvider() : null,
|
||||
this.getRecordingMenuProvider(),
|
||||
this.getSecondaryMenuProvider(),
|
||||
].filter(p => p !== null);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ let package = Package(
|
||||
.library(name: "AffineGraphQL", targets: ["AffineGraphQL"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apollographql/apollo-ios", exact: "1.18.0"),
|
||||
.package(url: "https://github.com/apollographql/apollo-ios", exact: "1.19.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
||||
@@ -16,7 +16,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.package(path: "../AffineGraphQL"),
|
||||
.package(path: "../MarkdownView"),
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.18.0"),
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.19.0"),
|
||||
.package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", from: "3.3.0"),
|
||||
.package(url: "https://github.com/apple/swift-collections", from: "1.1.4"),
|
||||
.package(url: "https://github.com/Lakr233/ChidoriMenu", from: "2.4.3"),
|
||||
|
||||
@@ -14,7 +14,7 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/JohnSundell/Splash", from: "0.16.0"),
|
||||
.package(url: "https://github.com/swiftlang/swift-cmark", from: "0.4.0"),
|
||||
.package(url: "https://github.com/swiftlang/swift-cmark", from: "0.5.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "MarkdownView", dependencies: [
|
||||
|
||||
@@ -46,9 +46,11 @@
|
||||
"@radix-ui/react-visually-hidden": "^1.1.1",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"@vanilla-extract/dynamic": "^2.1.2",
|
||||
"bytes": "^3.1.2",
|
||||
"check-password-strength": "^3.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"foxact": "^0.2.45",
|
||||
"jotai": "^2.10.3",
|
||||
"lit": "^3.2.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -77,6 +79,7 @@
|
||||
"@storybook/react-vite": "^8.4.7",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
|
||||
@@ -29,6 +29,10 @@ export const wrapper = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const wrapperDisabled = style({
|
||||
opacity: 0.5,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
globalStyle(`${wrapper} .title`, {
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import clsx from 'clsx';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { wrapper } from './share.css';
|
||||
import { wrapper, wrapperDisabled } from './share.css';
|
||||
|
||||
interface SettingWrapperProps {
|
||||
title?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SettingWrapper = ({
|
||||
title,
|
||||
children,
|
||||
disabled,
|
||||
}: PropsWithChildren<SettingWrapperProps>) => {
|
||||
return (
|
||||
<div className={wrapper}>
|
||||
<div className={clsx(wrapper, disabled && wrapperDisabled)}>
|
||||
{title ? <div className="title">{title}</div> : null}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './hooks';
|
||||
export * from './lit-react';
|
||||
export * from './styles';
|
||||
export * from './ui/audio-player';
|
||||
export * from './ui/avatar';
|
||||
export * from './ui/button';
|
||||
export * from './ui/checkbox';
|
||||
@@ -14,9 +15,7 @@ export * from './ui/error-message';
|
||||
export * from './ui/input';
|
||||
export * from './ui/layout';
|
||||
export * from './ui/loading';
|
||||
export * from './ui/lottie/collections-icon';
|
||||
export * from './ui/lottie/delete-icon';
|
||||
export * from './ui/lottie/folder-icon';
|
||||
export * from './ui/lottie';
|
||||
export * from './ui/masonry';
|
||||
export * from './ui/menu';
|
||||
export * from './ui/modal';
|
||||
|
||||
@@ -4,8 +4,9 @@ import React, { createElement, type ReactNode } from 'react';
|
||||
|
||||
import { createComponent } from './create-component';
|
||||
|
||||
export
|
||||
@customElement('affine-lit-template-wrapper')
|
||||
export class LitTemplateWrapper extends LitElement {
|
||||
class LitTemplateWrapper extends LitElement {
|
||||
static override get properties() {
|
||||
return {
|
||||
template: { type: Object },
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ export const controlButton = style({
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
color: cssVarV2('text/primary'),
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const controls = style({
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AudioPlayer, MiniAudioPlayer } from './audio-player';
|
||||
|
||||
const AudioWrapper = () => {
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [waveform, setWaveform] = useState<number[] | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<
|
||||
'idle' | 'playing' | 'paused' | 'stopped'
|
||||
>('idle');
|
||||
const [seekTime, setSeekTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Generate waveform data from audio file
|
||||
const generateWaveform = async (audioBuffer: AudioBuffer) => {
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samples = 1000;
|
||||
const blockSize = Math.floor(channelData.length / samples);
|
||||
const waveformData = [];
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const start = i * blockSize;
|
||||
const end = start + blockSize;
|
||||
let sum = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
sum += Math.abs(channelData[j]);
|
||||
}
|
||||
waveformData.push(sum / blockSize);
|
||||
}
|
||||
|
||||
// Normalize waveform data
|
||||
const max = Math.max(...waveformData);
|
||||
return waveformData.map(val => val / max);
|
||||
};
|
||||
|
||||
const handleFileChange = useCallback(async (file: File) => {
|
||||
setLoading(true);
|
||||
setAudioFile(file);
|
||||
setPlaybackState('idle');
|
||||
setSeekTime(0);
|
||||
setDuration(0);
|
||||
setWaveform(null);
|
||||
|
||||
// Revoke previous URL if exists
|
||||
if (audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
|
||||
// Create new URL for the audio file
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
audioUrlRef.current = fileUrl;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioContext = new AudioContext();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const waveformData = await generateWaveform(audioBuffer);
|
||||
setWaveform(waveformData);
|
||||
} catch (error) {
|
||||
console.error('Error processing audio file:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup object URL when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('audio/')) {
|
||||
handleFileChange(file);
|
||||
}
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileChange(file);
|
||||
}
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
|
||||
const handlePlay = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
const playPromise = audioRef.current.play();
|
||||
|
||||
// Handle play promise to catch any errors
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setPlaybackState('playing');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
setPlaybackState('paused');
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (audioRef.current) {
|
||||
// Ensure time is within valid range
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(time, audioRef.current.duration)
|
||||
);
|
||||
audioRef.current.currentTime = clampedTime;
|
||||
if (playbackState === 'stopped') {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
}
|
||||
},
|
||||
[playbackState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audioFile) return;
|
||||
|
||||
const updateTime = () => {
|
||||
setSeekTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const updateDuration = () => {
|
||||
if (!isNaN(audio.duration) && isFinite(audio.duration)) {
|
||||
setDuration(audio.duration);
|
||||
setPlaybackState('paused');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle direct interaction with audio element controls
|
||||
const handleNativeTimeUpdate = () => {
|
||||
setSeekTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const handleNativePlay = () => {
|
||||
setPlaybackState('playing');
|
||||
};
|
||||
|
||||
const handleNativePause = () => {
|
||||
if (audio.currentTime >= audio.duration - 0.1) {
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
} else {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
};
|
||||
|
||||
const handlePlaying = () => {
|
||||
setPlaybackState('playing');
|
||||
};
|
||||
|
||||
const handlePaused = () => {
|
||||
if (audio.currentTime === 0) {
|
||||
setPlaybackState('stopped');
|
||||
} else {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
console.error('Audio playback error');
|
||||
setPlaybackState('stopped');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleWaiting = () => {
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Add all event listeners
|
||||
audio.addEventListener('timeupdate', updateTime);
|
||||
audio.addEventListener('seeking', handleNativeTimeUpdate);
|
||||
audio.addEventListener('seeked', handleNativeTimeUpdate);
|
||||
audio.addEventListener('play', handleNativePlay);
|
||||
audio.addEventListener('pause', handleNativePause);
|
||||
audio.addEventListener('loadedmetadata', updateDuration);
|
||||
audio.addEventListener('durationchange', updateDuration);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
audio.addEventListener('playing', handlePlaying);
|
||||
audio.addEventListener('pause', handlePaused);
|
||||
audio.addEventListener('error', handleError);
|
||||
audio.addEventListener('waiting', handleWaiting);
|
||||
audio.addEventListener('canplay', handleCanPlay);
|
||||
|
||||
return () => {
|
||||
// Remove all event listeners
|
||||
audio.removeEventListener('timeupdate', updateTime);
|
||||
audio.removeEventListener('seeking', handleNativeTimeUpdate);
|
||||
audio.removeEventListener('seeked', handleNativeTimeUpdate);
|
||||
audio.removeEventListener('play', handleNativePlay);
|
||||
audio.removeEventListener('pause', handleNativePause);
|
||||
audio.removeEventListener('loadedmetadata', updateDuration);
|
||||
audio.removeEventListener('durationchange', updateDuration);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
audio.removeEventListener('playing', handlePlaying);
|
||||
audio.removeEventListener('pause', handlePaused);
|
||||
audio.removeEventListener('error', handleError);
|
||||
audio.removeEventListener('waiting', handleWaiting);
|
||||
audio.removeEventListener('canplay', handleCanPlay);
|
||||
};
|
||||
}, [audioFile]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '200px',
|
||||
border: '2px dashed #ccc',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
gap: '20px',
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
>
|
||||
{!audioFile ? (
|
||||
<>
|
||||
<div>Drag & drop an audio file here, or</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{ maxWidth: '200px' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrlRef.current || ''}
|
||||
preload="metadata"
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '600px' }}
|
||||
/>
|
||||
<MiniAudioPlayer
|
||||
name={audioFile.name}
|
||||
size={audioFile.size}
|
||||
waveform={waveform}
|
||||
playbackState={playbackState}
|
||||
seekTime={seekTime}
|
||||
duration={duration}
|
||||
loading={loading}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
<AudioPlayer
|
||||
name={audioFile.name}
|
||||
size={audioFile.size}
|
||||
waveform={waveform}
|
||||
playbackState={playbackState}
|
||||
seekTime={seekTime}
|
||||
duration={duration}
|
||||
loading={loading}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof AudioWrapper> = {
|
||||
title: 'UI/AudioPlayer',
|
||||
component: AudioWrapper,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AudioWrapper>;
|
||||
|
||||
export const Default: Story = {};
|
||||
+2
-2
@@ -1,4 +1,3 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import {
|
||||
AddThirtySecondIcon,
|
||||
CloseIcon,
|
||||
@@ -9,9 +8,10 @@ import bytes from 'bytes';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { type MouseEventHandler, type ReactNode, useCallback } from 'react';
|
||||
|
||||
import { IconButton } from '../button';
|
||||
import { AnimatedPlayIcon } from '../lottie';
|
||||
import * as styles from './audio-player.css';
|
||||
import { AudioWaveform } from './audio-waveform';
|
||||
import { AnimatedPlayIcon } from './lottie/animated-play-icon';
|
||||
|
||||
// Format seconds to mm:ss
|
||||
const formatTime = (seconds: number): string => {
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './audio-player';
|
||||
export * from './audio-waveform';
|
||||
@@ -0,0 +1,3 @@
|
||||
# Sortable
|
||||
|
||||
Migrated from https://github.com/clauderic/dnd-kit
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ElementDragType } from '@atlaskit/pragmatic-drag-and-drop/types';
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { rectSortingStrategy } from './strategies';
|
||||
import type {
|
||||
ClientRect,
|
||||
Disabled,
|
||||
SortingStrategy,
|
||||
UniqueIdentifier,
|
||||
} from './types';
|
||||
import {
|
||||
getSortedRects,
|
||||
itemsEqual,
|
||||
normalizeDisabled,
|
||||
useUniqueId,
|
||||
} from './utilities';
|
||||
|
||||
export interface Props {
|
||||
children: React.ReactNode;
|
||||
items: (UniqueIdentifier | { id: UniqueIdentifier })[];
|
||||
strategy?: SortingStrategy;
|
||||
disabled?: boolean | Disabled;
|
||||
}
|
||||
|
||||
const ID_PREFIX = 'Sortable';
|
||||
|
||||
interface ContextDescriptor {
|
||||
activeIndex: number;
|
||||
containerId: string;
|
||||
disableTransforms: boolean;
|
||||
items: {
|
||||
id: UniqueIdentifier;
|
||||
}[];
|
||||
overIndex: number;
|
||||
sortedRects: ClientRect[];
|
||||
strategy: SortingStrategy;
|
||||
disabled: Disabled;
|
||||
}
|
||||
|
||||
export const Context = React.createContext<ContextDescriptor>({
|
||||
activeIndex: -1,
|
||||
containerId: ID_PREFIX,
|
||||
disableTransforms: false,
|
||||
items: [],
|
||||
overIndex: -1,
|
||||
sortedRects: [],
|
||||
strategy: rectSortingStrategy,
|
||||
disabled: {
|
||||
draggable: false,
|
||||
droppable: false,
|
||||
},
|
||||
});
|
||||
|
||||
export function SortableContext({
|
||||
children,
|
||||
items: userDefinedItems,
|
||||
strategy = rectSortingStrategy,
|
||||
disabled: disabledProp = false,
|
||||
}: Props) {
|
||||
const [active, setActive] = useState<ElementDragType | null>(null);
|
||||
|
||||
const { active, droppableRects, over, measureDroppableContainers } =
|
||||
useDndContext();
|
||||
const containerId = useUniqueId(ID_PREFIX, id);
|
||||
const items = useMemo<UniqueIdentifier[]>(
|
||||
() =>
|
||||
userDefinedItems.map(item =>
|
||||
typeof item === 'object' && 'id' in item ? item.id : item
|
||||
),
|
||||
[userDefinedItems]
|
||||
);
|
||||
const isDragging = active != null;
|
||||
const activeIndex = active ? items.indexOf(active.id) : -1;
|
||||
const overIndex = over ? items.indexOf(over.id) : -1;
|
||||
const previousItemsRef = useRef(items);
|
||||
const itemsHaveChanged = !itemsEqual(items, previousItemsRef.current);
|
||||
const disableTransforms =
|
||||
(overIndex !== -1 && activeIndex === -1) || itemsHaveChanged;
|
||||
const disabled = normalizeDisabled(disabledProp);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemsHaveChanged && isDragging) {
|
||||
measureDroppableContainers(items);
|
||||
}
|
||||
}, [itemsHaveChanged, items, isDragging, measureDroppableContainers]);
|
||||
|
||||
useEffect(() => {
|
||||
previousItemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
(): ContextDescriptor => ({
|
||||
activeIndex,
|
||||
containerId,
|
||||
disabled,
|
||||
disableTransforms,
|
||||
items,
|
||||
overIndex,
|
||||
sortedRects: getSortedRects(items, droppableRects),
|
||||
strategy,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
activeIndex,
|
||||
containerId,
|
||||
disabled.draggable,
|
||||
disabled.droppable,
|
||||
disableTransforms,
|
||||
items,
|
||||
overIndex,
|
||||
droppableRects,
|
||||
strategy,
|
||||
]
|
||||
);
|
||||
|
||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SortingStrategy } from './types';
|
||||
import { arrayMove } from './utilities';
|
||||
|
||||
export const rectSortingStrategy: SortingStrategy = ({
|
||||
rects,
|
||||
activeIndex,
|
||||
overIndex,
|
||||
index,
|
||||
}) => {
|
||||
const newRects = arrayMove(rects, overIndex, activeIndex);
|
||||
|
||||
const oldRect = rects[index];
|
||||
const newRect = newRects[index];
|
||||
|
||||
if (!newRect || !oldRect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: newRect.left - oldRect.left,
|
||||
y: newRect.top - oldRect.top,
|
||||
scaleX: newRect.width / oldRect.width,
|
||||
scaleY: newRect.height / oldRect.height,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export type Transform = {
|
||||
x: number;
|
||||
y: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
};
|
||||
|
||||
export interface ClientRect {
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export type SortingStrategy = (args: {
|
||||
activeNodeRect: ClientRect | null;
|
||||
activeIndex: number;
|
||||
index: number;
|
||||
rects: ClientRect[];
|
||||
overIndex: number;
|
||||
}) => Transform | null;
|
||||
|
||||
export type UniqueIdentifier = string | number;
|
||||
|
||||
export type RectMap = Map<UniqueIdentifier, ClientRect>;
|
||||
|
||||
export interface Disabled {
|
||||
draggable?: boolean;
|
||||
droppable?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { ClientRect, Disabled, RectMap, UniqueIdentifier } from './types';
|
||||
|
||||
let ids: Record<string, number> = {};
|
||||
|
||||
export function useUniqueId(prefix: string, value?: string) {
|
||||
return useMemo(() => {
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const id = ids[prefix] == null ? 0 : ids[prefix] + 1;
|
||||
ids[prefix] = id;
|
||||
|
||||
return `${prefix}-${id}`;
|
||||
}, [prefix, value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an array item to a different position. Returns a new array with the item moved to the new position.
|
||||
*/
|
||||
export function arrayMove<T>(array: T[], from: number, to: number): T[] {
|
||||
const newArray = array.slice();
|
||||
newArray.splice(
|
||||
to < 0 ? newArray.length + to : to,
|
||||
0,
|
||||
newArray.splice(from, 1)[0]
|
||||
);
|
||||
|
||||
return newArray;
|
||||
}
|
||||
|
||||
export function getSortedRects(items: UniqueIdentifier[], rects: RectMap) {
|
||||
return items.reduce<ClientRect[]>(
|
||||
(accumulator, id, index) => {
|
||||
const rect = rects.get(id);
|
||||
|
||||
if (rect) {
|
||||
accumulator[index] = rect;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
Array.from({ length: items.length })
|
||||
);
|
||||
}
|
||||
|
||||
export function itemsEqual(a: UniqueIdentifier[], b: UniqueIdentifier[]) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalizeDisabled(disabled: boolean | Disabled): Disabled {
|
||||
if (typeof disabled === 'boolean') {
|
||||
return {
|
||||
draggable: disabled,
|
||||
droppable: disabled,
|
||||
};
|
||||
}
|
||||
|
||||
return disabled;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AnimatedPlayIcon } from './animated-play-icon';
|
||||
|
||||
export default {
|
||||
title: 'UI/Audio Player/Animated Play Icon',
|
||||
component: AnimatedPlayIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'An animated icon that transitions between play, pause, and loading states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AnimatedPlayIcon>;
|
||||
|
||||
const Template: StoryFn<typeof AnimatedPlayIcon> = args => (
|
||||
<AnimatedPlayIcon {...args} />
|
||||
);
|
||||
|
||||
export const Play = Template.bind({});
|
||||
Play.args = {
|
||||
state: 'play',
|
||||
};
|
||||
|
||||
export const Pause = Template.bind({});
|
||||
Pause.args = {
|
||||
state: 'pause',
|
||||
};
|
||||
|
||||
export const Loading = Template.bind({});
|
||||
Loading.args = {
|
||||
state: 'loading',
|
||||
};
|
||||
|
||||
export const WithStateToggle: StoryFn<typeof AnimatedPlayIcon> = () => {
|
||||
const [state, setState] = useState<'play' | 'pause' | 'loading'>('play');
|
||||
|
||||
const cycleState = () => {
|
||||
setState(current => {
|
||||
switch (current) {
|
||||
case 'play':
|
||||
return 'pause';
|
||||
case 'pause':
|
||||
return 'play';
|
||||
case 'loading':
|
||||
return 'play';
|
||||
default:
|
||||
return 'play';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<AnimatedPlayIcon state={state} />
|
||||
<button onClick={cycleState}>Toggle State (Current: {state})</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import clsx from 'clsx';
|
||||
import { useDebouncedValue } from 'foxact/use-debounced-value';
|
||||
import type { LottieRef } from 'lottie-react';
|
||||
import Lottie from 'lottie-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { Loading } from '../loading';
|
||||
import playandpause from './playandpause.json';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export interface AnimatedPlayIconProps {
|
||||
state: 'play' | 'pause' | 'loading';
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const PlayAndPauseIcon = ({
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
}: {
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
state: 'play' | 'pause';
|
||||
}) => {
|
||||
const lottieRef: LottieRef = useRef(null);
|
||||
const prevStateRef = useRef(state);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lottieRef.current) return;
|
||||
const lottie = lottieRef.current;
|
||||
lottie.setSpeed(2);
|
||||
|
||||
// Only animate if state actually changed
|
||||
if (prevStateRef.current !== state) {
|
||||
if (state === 'play') {
|
||||
// Animate from pause to play
|
||||
lottie.playSegments([120, 160], true);
|
||||
} else {
|
||||
// Animate from play to pause
|
||||
lottie.playSegments([60, 100], true);
|
||||
}
|
||||
prevStateRef.current = state;
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Lottie
|
||||
onClick={onClick}
|
||||
lottieRef={lottieRef}
|
||||
className={clsx(styles.root, className)}
|
||||
animationData={playandpause}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnimatedPlayIcon = ({
|
||||
state: _state,
|
||||
className,
|
||||
onClick,
|
||||
}: AnimatedPlayIconProps) => {
|
||||
const state = useDebouncedValue(_state, 25);
|
||||
if (state === 'loading') {
|
||||
return <Loading size={40} />;
|
||||
}
|
||||
return (
|
||||
<PlayAndPauseIcon state={state} onClick={onClick} className={className} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AnimatedTranscribeIcon } from './animated-transcribe-icon';
|
||||
|
||||
export default {
|
||||
title: 'UI/Audio Player/Animated Transcribe Icon',
|
||||
component: AnimatedTranscribeIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'An animated icon that shows transcription state with smooth transitions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AnimatedTranscribeIcon>;
|
||||
|
||||
const Template: StoryFn<typeof AnimatedTranscribeIcon> = args => (
|
||||
<AnimatedTranscribeIcon {...args} />
|
||||
);
|
||||
|
||||
export const Idle = Template.bind({});
|
||||
Idle.args = {
|
||||
state: 'idle',
|
||||
};
|
||||
|
||||
export const Transcribing = Template.bind({});
|
||||
Transcribing.args = {
|
||||
state: 'transcribing',
|
||||
};
|
||||
|
||||
export const WithStateToggle: StoryFn<typeof AnimatedTranscribeIcon> = () => {
|
||||
const [state, setState] = useState<'idle' | 'transcribing'>('idle');
|
||||
|
||||
const toggleState = () => {
|
||||
setState(current => (current === 'idle' ? 'transcribing' : 'idle'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<AnimatedTranscribeIcon state={state} />
|
||||
<button onClick={toggleState}>Toggle State (Current: {state})</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './animated-play-icon';
|
||||
export * from './animated-transcribe-icon';
|
||||
export * from './collections-icon';
|
||||
export * from './delete-icon';
|
||||
export * from './folder-icon';
|
||||
+167
-15
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 60,
|
||||
"ip": 60,
|
||||
"op": 103,
|
||||
"ip": 0,
|
||||
"op": 161,
|
||||
"w": 40,
|
||||
"h": 40,
|
||||
"nm": "pause to play",
|
||||
@@ -12,8 +12,160 @@
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 3,
|
||||
"nm": "Void::Icon (Stroke)",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [21.125, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"ef": [
|
||||
{
|
||||
"ty": 5,
|
||||
"nm": "Void",
|
||||
"np": 19,
|
||||
"mn": "Pseudo/250958",
|
||||
"ix": 1,
|
||||
"en": 1,
|
||||
"ef": [
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Width",
|
||||
"mn": "Pseudo/250958-0001",
|
||||
"ix": 1,
|
||||
"v": { "a": 0, "k": 100, "ix": 1 }
|
||||
},
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Height",
|
||||
"mn": "Pseudo/250958-0002",
|
||||
"ix": 2,
|
||||
"v": { "a": 0, "k": 100, "ix": 2 }
|
||||
},
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Offset X",
|
||||
"mn": "Pseudo/250958-0003",
|
||||
"ix": 3,
|
||||
"v": { "a": 0, "k": 0, "ix": 3 }
|
||||
},
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Offset Y",
|
||||
"mn": "Pseudo/250958-0004",
|
||||
"ix": 4,
|
||||
"v": { "a": 0, "k": 0, "ix": 4 }
|
||||
},
|
||||
{
|
||||
"ty": 0,
|
||||
"nm": "Roundness",
|
||||
"mn": "Pseudo/250958-0005",
|
||||
"ix": 5,
|
||||
"v": { "a": 0, "k": 0, "ix": 5 }
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "About",
|
||||
"mn": "Pseudo/250958-0006",
|
||||
"ix": 6,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Plague of null layers.",
|
||||
"mn": "Pseudo/250958-0007",
|
||||
"ix": 7,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0008",
|
||||
"ix": 8,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Following projects",
|
||||
"mn": "Pseudo/250958-0009",
|
||||
"ix": 9,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0010",
|
||||
"ix": 10,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "through time.",
|
||||
"mn": "Pseudo/250958-0011",
|
||||
"ix": 11,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0012",
|
||||
"ix": 12,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Be free of the past.",
|
||||
"mn": "Pseudo/250958-0013",
|
||||
"ix": 13,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0014",
|
||||
"ix": 14,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Copyright 2023 Battle Axe Inc",
|
||||
"mn": "Pseudo/250958-0015",
|
||||
"ix": 15,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0016",
|
||||
"ix": 16,
|
||||
"v": 0
|
||||
},
|
||||
{
|
||||
"ty": 6,
|
||||
"nm": "Void",
|
||||
"mn": "Pseudo/250958-0017",
|
||||
"ix": 17,
|
||||
"v": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ty": 4,
|
||||
"nm": "Icon (Stroke)",
|
||||
"parent": 1,
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
@@ -42,7 +194,7 @@
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"p": { "a": 0, "k": [0, 0, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
@@ -54,7 +206,7 @@
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.02] },
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 1] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [0, 0, 0] },
|
||||
"t": 90,
|
||||
"s": [32, 32, 100]
|
||||
@@ -67,11 +219,11 @@
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.999, 0.999, 0] },
|
||||
"t": 143,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{ "t": 159, "s": [100, 100, 100] }
|
||||
{ "t": 160, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
@@ -200,7 +352,7 @@
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ind": 3,
|
||||
"ty": 4,
|
||||
"nm": "Union",
|
||||
"sr": 1,
|
||||
@@ -244,14 +396,14 @@
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.999, 0.999, 0] },
|
||||
"t": 83,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833] },
|
||||
"o": { "x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167] },
|
||||
"t": 99,
|
||||
"t": 100,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
@@ -406,9 +558,9 @@
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 3,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 3",
|
||||
"nm": "wave",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
@@ -516,9 +668,9 @@
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 2",
|
||||
"nm": "wave",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
@@ -626,9 +778,9 @@
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ind": 6,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 1",
|
||||
"nm": "circle",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
@@ -1,15 +1,65 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
width: '1em',
|
||||
display: 'inline-flex',
|
||||
height: '1em',
|
||||
display: 'flex',
|
||||
width: '1em',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
const magicColor = `rgb(119,117,125)`;
|
||||
globalStyle(`${root} path[stroke="${magicColor}"]`, {
|
||||
stroke: 'currentColor',
|
||||
});
|
||||
globalStyle(`${root} path[fill="${magicColor}"]`, {
|
||||
fill: 'currentColor',
|
||||
});
|
||||
|
||||
// replace primary colors to cssVarV2('icon/primary')
|
||||
const iconPrimaryColors = [
|
||||
// legacy "--affine-icon-color"
|
||||
'rgb(119,117,125)',
|
||||
// --affine-v2-icon-primary
|
||||
'rgb(122,122,122)',
|
||||
];
|
||||
|
||||
// todo: may need to replace secondary colors & background colors as well?
|
||||
|
||||
const backgroundPrimaryColors = [
|
||||
// --affine-v2-background-primary
|
||||
'rgb(255,255,255)',
|
||||
'#ffffff',
|
||||
];
|
||||
|
||||
const backgroundSecondaryColors = [
|
||||
// --affine-v2-background-secondary
|
||||
'rgb(245,245,245)',
|
||||
];
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${iconPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: 'currentColor',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${iconPrimaryColors.map(color => `path[stroke="${color}"]`).join(',')})`,
|
||||
{
|
||||
stroke: 'currentColor',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundPrimaryColors.map(color => `rect[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: 'transparent',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: 'transparent',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${root} :is(${backgroundSecondaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
|
||||
{
|
||||
fill: cssVarV2('layer/background/secondary'),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swr": "2.3.3",
|
||||
"tinykeys": "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch",
|
||||
"webm-muxer": "^5.1.0",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
export const translateSubItem: AISubItemConfig[] = translateLangs.map(lang => {
|
||||
return {
|
||||
type: lang,
|
||||
testId: `action-translate-${lang}`,
|
||||
handler: actionToHandler('translate', AIStarIconWithAnimation, { lang }),
|
||||
};
|
||||
});
|
||||
@@ -56,6 +57,7 @@ export const translateSubItem: AISubItemConfig[] = translateLangs.map(lang => {
|
||||
export const toneSubItem: AISubItemConfig[] = textTones.map(tone => {
|
||||
return {
|
||||
type: tone,
|
||||
testId: `action-change-tone-${tone.toLowerCase()}`,
|
||||
handler: actionToHandler('changeTone', AIStarIconWithAnimation, { tone }),
|
||||
};
|
||||
});
|
||||
@@ -66,6 +68,7 @@ export function createImageFilterSubItem(
|
||||
return imageFilterStyles.map(style => {
|
||||
return {
|
||||
type: style,
|
||||
testId: `action-image-filter-${style.toLowerCase().replace(' ', '-')}`,
|
||||
handler: actionToHandler(
|
||||
'filterImage',
|
||||
AIImageIconWithAnimation,
|
||||
@@ -84,6 +87,7 @@ export function createImageProcessingSubItem(
|
||||
return imageProcessingTypes.map(type => {
|
||||
return {
|
||||
type,
|
||||
testId: `action-image-processing-${type.toLowerCase().replace(' ', '-')}`,
|
||||
handler: actionToHandler(
|
||||
'processImage',
|
||||
AIImageIconWithAnimation,
|
||||
@@ -146,36 +150,42 @@ const EditAIGroup: AIItemGroupConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Translate to',
|
||||
testId: 'action-translate',
|
||||
icon: LanguageIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
subItem: translateSubItem,
|
||||
},
|
||||
{
|
||||
name: 'Change tone to',
|
||||
testId: 'action-change-tone',
|
||||
icon: ToneIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
subItem: toneSubItem,
|
||||
},
|
||||
{
|
||||
name: 'Improve writing',
|
||||
testId: 'action-improve-writing',
|
||||
icon: ImproveWritingIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('improveWriting', AIStarIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Make it longer',
|
||||
testId: 'action-make-it-longer',
|
||||
icon: LongerIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('makeLonger', AIStarIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Make it shorter',
|
||||
testId: 'action-make-it-shorter',
|
||||
icon: ShorterIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('makeShorter', AIStarIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Continue writing',
|
||||
testId: 'action-continue-writing',
|
||||
icon: PenIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('continueWriting', AIPenIconWithAnimation),
|
||||
@@ -188,30 +198,35 @@ const DraftAIGroup: AIItemGroupConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Write an article about this',
|
||||
testId: 'action-write-article',
|
||||
icon: PenIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('writeArticle', AIPenIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Write a tweet about this',
|
||||
testId: 'action-write-twitter-post',
|
||||
icon: PenIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('writeTwitterPost', AIPenIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Write a poem about this',
|
||||
testId: 'action-write-poem',
|
||||
icon: PenIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('writePoem', AIPenIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Write a blog post about this',
|
||||
testId: 'action-write-blog-post',
|
||||
icon: PenIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('writeBlogPost', AIPenIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Brainstorm ideas about this',
|
||||
testId: 'action-brainstorm',
|
||||
icon: PenIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('brainstorm', AIPenIconWithAnimation),
|
||||
@@ -224,36 +239,42 @@ const ReviewWIthAIGroup: AIItemGroupConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Fix spelling',
|
||||
testId: 'action-fix-spelling',
|
||||
icon: DoneIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('fixSpelling', AIStarIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Fix grammar',
|
||||
testId: 'action-fix-grammar',
|
||||
icon: DoneIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('improveGrammar', AIStarIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Explain this image',
|
||||
testId: 'action-explain-image',
|
||||
icon: PenIcon(),
|
||||
showWhen: imageBlockShowWhen,
|
||||
handler: actionToHandler('explainImage', AIStarIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Explain this code',
|
||||
testId: 'action-explain-code',
|
||||
icon: ExplainIcon(),
|
||||
showWhen: codeBlockShowWhen,
|
||||
handler: actionToHandler('explainCode', AIStarIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Check code error',
|
||||
testId: 'action-check-code-error',
|
||||
icon: ExplainIcon(),
|
||||
showWhen: codeBlockShowWhen,
|
||||
handler: actionToHandler('checkCodeErrors', AIStarIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Explain selection',
|
||||
testId: 'action-explain-selection',
|
||||
icon: SelectionIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('explain', AIStarIconWithAnimation),
|
||||
@@ -266,12 +287,14 @@ const GenerateWithAIGroup: AIItemGroupConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Summarize',
|
||||
testId: 'action-summarize',
|
||||
icon: PenIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('summary', AIPenIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Generate headings',
|
||||
testId: 'action-generate-headings',
|
||||
icon: PenIcon(),
|
||||
beta: true,
|
||||
handler: actionToHandler('createHeadings', AIPenIconWithAnimation),
|
||||
@@ -293,24 +316,28 @@ const GenerateWithAIGroup: AIItemGroupConfig = {
|
||||
},
|
||||
{
|
||||
name: 'Generate an image',
|
||||
testId: 'action-generate-image',
|
||||
icon: ImageIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('createImage', AIImageIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Generate outline',
|
||||
testId: 'action-generate-outline',
|
||||
icon: PenIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('writeOutline', AIPenIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Brainstorm ideas with mind map',
|
||||
testId: 'action-brainstorm-mindmap',
|
||||
icon: MindmapIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('brainstormMindmap', AIPenIconWithAnimation),
|
||||
},
|
||||
{
|
||||
name: 'Generate presentation',
|
||||
testId: 'action-generate-presentation',
|
||||
icon: PresentationIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('createSlides', AIPresentationIconWithAnimation),
|
||||
@@ -318,6 +345,7 @@ const GenerateWithAIGroup: AIItemGroupConfig = {
|
||||
},
|
||||
{
|
||||
name: 'Make it real',
|
||||
testId: 'action-make-it-real',
|
||||
icon: MakeItRealIcon(),
|
||||
beta: true,
|
||||
showWhen: textBlockShowWhen,
|
||||
@@ -325,6 +353,7 @@ const GenerateWithAIGroup: AIItemGroupConfig = {
|
||||
},
|
||||
{
|
||||
name: 'Find actions',
|
||||
testId: 'action-find-actions',
|
||||
icon: SearchIcon(),
|
||||
showWhen: textBlockShowWhen,
|
||||
handler: actionToHandler('findActions', AIStarIconWithAnimation),
|
||||
@@ -338,6 +367,7 @@ const OthersAIGroup: AIItemGroupConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Continue with AI',
|
||||
testId: 'action-continue-with-ai',
|
||||
icon: CommentIcon(),
|
||||
handler: host => {
|
||||
const panel = getAIPanelWidget(host);
|
||||
@@ -366,6 +396,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
|
||||
items: [
|
||||
{
|
||||
name: 'Explain this image',
|
||||
testId: 'action-explain-image',
|
||||
icon: ImageIcon(),
|
||||
showWhen: () => true,
|
||||
handler: actionToHandler(
|
||||
@@ -382,6 +413,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
|
||||
items: [
|
||||
{
|
||||
name: 'Generate an image',
|
||||
testId: 'action-generate-image',
|
||||
icon: ImageIcon(),
|
||||
showWhen: () => true,
|
||||
handler: actionToHandler(
|
||||
@@ -393,6 +425,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
|
||||
},
|
||||
{
|
||||
name: 'Image processing',
|
||||
testId: 'action-image-processing',
|
||||
icon: ImageIcon(),
|
||||
showWhen: () => true,
|
||||
subItem: createImageProcessingSubItem(blockActionTrackerOptions),
|
||||
@@ -401,6 +434,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
|
||||
},
|
||||
{
|
||||
name: 'AI image filter',
|
||||
testId: 'action-ai-image-filter',
|
||||
icon: ImproveWritingIcon(),
|
||||
showWhen: () => true,
|
||||
subItem: createImageFilterSubItem(blockActionTrackerOptions),
|
||||
@@ -409,6 +443,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
|
||||
},
|
||||
{
|
||||
name: 'Generate a caption',
|
||||
testId: 'action-generate-caption',
|
||||
icon: PenIcon(),
|
||||
showWhen: () => true,
|
||||
beta: true,
|
||||
@@ -432,6 +467,7 @@ export function buildAICodeItemGroups(): AIItemGroupConfig[] {
|
||||
items: [
|
||||
{
|
||||
name: 'Explain this code',
|
||||
testId: 'action-explain-code',
|
||||
icon: ExplainIcon(),
|
||||
showWhen: () => true,
|
||||
handler: actionToHandler(
|
||||
@@ -443,6 +479,7 @@ export function buildAICodeItemGroups(): AIItemGroupConfig[] {
|
||||
},
|
||||
{
|
||||
name: 'Check code error',
|
||||
testId: 'action-check-code-error',
|
||||
icon: ExplainIcon(),
|
||||
showWhen: () => true,
|
||||
handler: actionToHandler(
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
buildFinishConfig,
|
||||
buildGeneratingConfig,
|
||||
} from '../ai-panel';
|
||||
import type { AIError, AIItemGroupConfig } from '../components/ai-item/types';
|
||||
import { AIProvider } from '../provider';
|
||||
import { type AIItemGroupConfig } from '../components/ai-item/types';
|
||||
import { type AIError, AIProvider } from '../provider';
|
||||
import { reportResponse } from '../utils/action-reporter';
|
||||
import { getAIPanelWidget } from '../utils/ai-widgets';
|
||||
import { AIContext } from '../utils/context';
|
||||
|
||||
@@ -20,8 +20,7 @@ import type { TemplateResult } from 'lit';
|
||||
|
||||
import { getContentFromSlice } from '../../utils';
|
||||
import { AIChatBlockModel } from '../blocks';
|
||||
import type { AIError } from '../components/ai-item/types';
|
||||
import { AIProvider } from '../provider';
|
||||
import { type AIError, AIProvider } from '../provider';
|
||||
import { reportResponse } from '../utils/action-reporter';
|
||||
import { getAIPanelWidget } from '../utils/ai-widgets';
|
||||
import { AIContext } from '../utils/context';
|
||||
@@ -462,7 +461,6 @@ export function noteBlockOrTextShowWhen(
|
||||
host: EditorHost
|
||||
) {
|
||||
const selected = getCopilotSelectedElems(host);
|
||||
|
||||
return selected.some(
|
||||
el =>
|
||||
el instanceof NoteBlockModel ||
|
||||
|
||||
@@ -85,6 +85,7 @@ export function discard(
|
||||
return {
|
||||
name: 'Discard',
|
||||
icon: DeleteIcon(),
|
||||
testId: 'answer-discard',
|
||||
showWhen: () => !!panel.answer,
|
||||
handler: () => {
|
||||
panel.discard();
|
||||
@@ -96,6 +97,7 @@ export function retry(panel: AffineAIPanelWidget): AIItemConfig {
|
||||
return {
|
||||
name: 'Retry',
|
||||
icon: ResetIcon(),
|
||||
testId: 'answer-retry',
|
||||
handler: () => {
|
||||
reportResponse('result:retry');
|
||||
panel.generate();
|
||||
@@ -123,6 +125,7 @@ export function createInsertItems<T extends keyof BlockSuitePresets.AIActions>(
|
||||
icon: html`<div style=${styleMap({ height: '20px', width: '20px' })}>
|
||||
${LightLoadingIcon}
|
||||
</div>`,
|
||||
testId: 'answer-insert-below-loading',
|
||||
showWhen: () => {
|
||||
const panel = getAIPanelWidget(host);
|
||||
const data = ctx.get();
|
||||
@@ -137,6 +140,8 @@ export function createInsertItems<T extends keyof BlockSuitePresets.AIActions>(
|
||||
{
|
||||
name: buttonText,
|
||||
icon: InsertBelowIcon(),
|
||||
testId:
|
||||
buttonText === 'Replace' ? 'answer-replace' : `answer-insert-below`,
|
||||
showWhen: () => {
|
||||
const panel = getAIPanelWidget(host);
|
||||
const data = ctx.get();
|
||||
@@ -191,6 +196,7 @@ export function asCaption<T extends keyof BlockSuitePresets.AIActions>(
|
||||
return {
|
||||
name: 'Use as caption',
|
||||
icon: PenIcon(),
|
||||
testId: 'answer-use-as-caption',
|
||||
showWhen: () => {
|
||||
const panel = getAIPanelWidget(host);
|
||||
return id === 'generateCaption' && !!panel.answer;
|
||||
@@ -553,9 +559,11 @@ export function actionToResponse<T extends keyof BlockSuitePresets.AIActions>(
|
||||
responses: [
|
||||
{
|
||||
name: 'Response',
|
||||
testId: 'answer-responses',
|
||||
items: [
|
||||
{
|
||||
name: 'Continue in chat',
|
||||
testId: 'answer-continue-in-chat',
|
||||
icon: ChatWithAiIcon({}),
|
||||
handler: () => {
|
||||
reportResponse('result:continue-in-chat');
|
||||
|
||||
@@ -53,6 +53,7 @@ function asCaption<T extends keyof BlockSuitePresets.AIActions>(
|
||||
return {
|
||||
name: 'Use as caption',
|
||||
icon: PenIcon(),
|
||||
testId: 'answer-use-as-caption',
|
||||
showWhen: () => {
|
||||
const panel = getAIPanelWidget(host);
|
||||
return id === 'generateCaption' && !!panel.answer;
|
||||
@@ -79,6 +80,7 @@ function createNewNote(host: EditorHost): AIItemConfig {
|
||||
return {
|
||||
name: 'Create new note',
|
||||
icon: PageIcon(),
|
||||
testId: 'answer-create-new-note',
|
||||
showWhen: () => {
|
||||
const panel = getAIPanelWidget(host);
|
||||
return !!panel.answer && isInsideEdgelessEditor(host);
|
||||
@@ -147,9 +149,11 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||
return [
|
||||
{
|
||||
name: 'Response',
|
||||
testId: 'answer-responses',
|
||||
items: [
|
||||
{
|
||||
name: 'Insert below',
|
||||
testId: 'answer-insert-below',
|
||||
icon: InsertBelowIcon(),
|
||||
showWhen: () =>
|
||||
!!panel.answer && (!id || !INSERT_ABOVE_ACTIONS.includes(id)),
|
||||
@@ -161,6 +165,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||
},
|
||||
{
|
||||
name: 'Insert above',
|
||||
testId: 'answer-insert-above',
|
||||
icon: InsertTopIcon(),
|
||||
showWhen: () =>
|
||||
!!panel.answer && !!id && INSERT_ABOVE_ACTIONS.includes(id),
|
||||
@@ -173,6 +178,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||
asCaption(host, id),
|
||||
{
|
||||
name: 'Replace selection',
|
||||
testId: 'answer-replace',
|
||||
icon: ReplaceIcon(),
|
||||
showWhen: () =>
|
||||
!!panel.answer && !EXCLUDING_REPLACE_ACTIONS.includes(id),
|
||||
@@ -187,10 +193,12 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
testId: 'answer-common-responses',
|
||||
items: [
|
||||
{
|
||||
name: 'Continue in chat',
|
||||
icon: ChatWithAiIcon(),
|
||||
testId: 'answer-continue-in-chat',
|
||||
handler: () => {
|
||||
reportResponse('result:continue-in-chat');
|
||||
AIProvider.slots.requestOpenWithChat.next({ host });
|
||||
@@ -200,6 +208,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||
{
|
||||
name: 'Regenerate',
|
||||
icon: ResetIcon(),
|
||||
testId: 'answer-regenerate',
|
||||
handler: () => {
|
||||
reportResponse('result:retry');
|
||||
panel.generate();
|
||||
@@ -208,6 +217,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||
{
|
||||
name: 'Discard',
|
||||
icon: DeleteIcon(),
|
||||
testId: 'answer-discard',
|
||||
handler: () => {
|
||||
panel.discard();
|
||||
},
|
||||
@@ -225,6 +235,7 @@ export function buildErrorResponseConfig(panel: AffineAIPanelWidget) {
|
||||
{
|
||||
name: 'Retry',
|
||||
icon: ResetIcon(),
|
||||
testId: 'error-retry',
|
||||
showWhen: () => true,
|
||||
handler: () => {
|
||||
reportResponse('result:retry');
|
||||
@@ -234,6 +245,7 @@ export function buildErrorResponseConfig(panel: AffineAIPanelWidget) {
|
||||
{
|
||||
name: 'Discard',
|
||||
icon: DeleteIcon(),
|
||||
testId: 'error-discard',
|
||||
showWhen: () => !!panel.answer,
|
||||
handler: () => {
|
||||
panel.discard();
|
||||
|
||||
+14
-1
@@ -13,8 +13,21 @@ export class LitTranscriptionBlock extends BlockComponent<TranscriptionBlockMode
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
get lastCalloutBlock() {
|
||||
for (const child of this.model.children.toReversed()) {
|
||||
if (child.flavour === 'affine:callout') {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return this.std.host.renderChildren(this.model);
|
||||
return this.std.host.renderChildren(this.model, model => {
|
||||
// if model is the last transcription block, we should render it
|
||||
return model === this.lastCalloutBlock;
|
||||
});
|
||||
}
|
||||
|
||||
@property({ type: String, attribute: 'data-block-id' })
|
||||
|
||||
@@ -142,6 +142,7 @@ export class ActionWrapper extends WithDisposable(LitElement) {
|
||||
<slot></slot>
|
||||
<div
|
||||
class="action-name"
|
||||
data-testid="action-name"
|
||||
@click=${() => (this.promptShow = !this.promptShow)}
|
||||
>
|
||||
${icons[item.action] ? icons[item.action] : DoneIcon()}
|
||||
@@ -152,22 +153,27 @@ export class ActionWrapper extends WithDisposable(LitElement) {
|
||||
</div>
|
||||
${this.promptShow
|
||||
? html`
|
||||
<div class="answer-prompt">
|
||||
<div class="answer-prompt" data-testid="answer-prompt">
|
||||
<div class="subtitle">Answer</div>
|
||||
${HISTORY_IMAGE_ACTIONS.includes(item.action)
|
||||
? images &&
|
||||
html`<chat-content-images
|
||||
.images=${images}
|
||||
data-testid="generated-image"
|
||||
></chat-content-images>`
|
||||
: nothing}
|
||||
${answer
|
||||
? createTextRenderer(this.host, { customHeading: true })(answer)
|
||||
? createTextRenderer(this.host, {
|
||||
customHeading: true,
|
||||
testId: 'chat-message-action-answer',
|
||||
})(answer)
|
||||
: nothing}
|
||||
${originalText
|
||||
? html`<div class="subtitle prompt">Prompt</div>
|
||||
${createTextRenderer(this.host, { customHeading: true })(
|
||||
item.messages[0].content + originalText
|
||||
)}`
|
||||
${createTextRenderer(this.host, {
|
||||
customHeading: true,
|
||||
testId: 'chat-message-action-prompt',
|
||||
})(item.messages[0].content + originalText)}`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import './action-wrapper';
|
||||
import '../content/images';
|
||||
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
@@ -27,7 +24,10 @@ export class ActionImageToText extends WithDisposable(ShadowlessElement) {
|
||||
})}
|
||||
>
|
||||
${answer
|
||||
? html`<chat-content-images .images=${answer}></chat-content-images>`
|
||||
? html`<chat-content-images
|
||||
data-testid="original-images"
|
||||
.images=${answer}
|
||||
></chat-content-images>`
|
||||
: nothing}
|
||||
</div>
|
||||
</action-wrapper>`;
|
||||
|
||||
@@ -17,13 +17,19 @@ export class ActionImage extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor host!: EditorHost;
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'action-image';
|
||||
|
||||
protected override render() {
|
||||
const images = this.item.messages[0].attachments;
|
||||
|
||||
return html`<action-wrapper .host=${this.host} .item=${this.item}>
|
||||
<div style=${styleMap({ marginBottom: '12px' })}>
|
||||
${images
|
||||
? html`<chat-content-images .images=${images}></chat-content-images>`
|
||||
? html`<chat-content-images
|
||||
.images=${images}
|
||||
data-testid="original-image"
|
||||
></chat-content-images>`
|
||||
: nothing}
|
||||
</div>
|
||||
</action-wrapper>`;
|
||||
|
||||
@@ -54,6 +54,7 @@ export class ActionText extends WithDisposable(LitElement) {
|
||||
border: isCode ? 'none' : '1px solid var(--affine-border-color)',
|
||||
})}
|
||||
class="original-text"
|
||||
data-testid="original-text"
|
||||
>
|
||||
${createTextRenderer(this.host, {
|
||||
customHeading: true,
|
||||
|
||||
@@ -47,6 +47,9 @@ export class AILoading extends WithDisposable(LitElement) {
|
||||
@property({ attribute: false })
|
||||
accessor stopGenerating!: () => void;
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'ai-loading';
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="generating-tip">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
|
||||
import type { AIError } from '../components/ai-item/types';
|
||||
import type { AIError } from '../provider';
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
|
||||
@@ -43,6 +43,7 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
.chips-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -4px 0 -4px;
|
||||
}
|
||||
.add-button,
|
||||
.collapse-button,
|
||||
@@ -99,6 +100,9 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor searchMenuConfig!: SearchMenuConfig;
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'chat-panel-chips';
|
||||
|
||||
@query('.add-button')
|
||||
accessor addButton!: HTMLDivElement;
|
||||
|
||||
@@ -137,7 +141,11 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
const chips = isCollapsed ? allChips.slice(0, 1) : allChips;
|
||||
|
||||
return html`<div class="chips-wrapper">
|
||||
<div class="add-button" @click=${this._toggleAddDocMenu}>
|
||||
<div
|
||||
class="add-button"
|
||||
data-testid="chat-panel-with-button"
|
||||
@click=${this._toggleAddDocMenu}
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>
|
||||
${repeat(
|
||||
|
||||
@@ -14,8 +14,7 @@ import { property, query, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { ChatAbortIcon, ChatSendIcon } from '../_common/icons';
|
||||
import type { AIError } from '../components/ai-item/types';
|
||||
import { AIProvider } from '../provider';
|
||||
import { type AIError, AIProvider } from '../provider';
|
||||
import { reportResponse } from '../utils/action-reporter';
|
||||
import { readBlobAsURL } from '../utils/image';
|
||||
import type { AINetworkSearchConfig, DocDisplayConfig } from './chat-config';
|
||||
@@ -220,6 +219,9 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
@property({ attribute: false })
|
||||
accessor docDisplayConfig!: DocDisplayConfig;
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'chat-panel-input-container';
|
||||
|
||||
private get _isNetworkActive() {
|
||||
return (
|
||||
!!this.networkSearchConfig.visible.value &&
|
||||
@@ -335,7 +337,10 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
`
|
||||
: nothing}
|
||||
${this.chatContextValue.quote
|
||||
? html`<div class="chat-selection-quote">
|
||||
? html`<div
|
||||
class="chat-selection-quote"
|
||||
data-testid="chat-selection-quote"
|
||||
>
|
||||
${repeat(
|
||||
getFirstTwoLines(this.chatContextValue.quote),
|
||||
line => line,
|
||||
@@ -420,6 +425,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
: nothing}
|
||||
${images.length < MaximumImageCount
|
||||
? html`<div
|
||||
data-testid="chat-panel-input-image-upload"
|
||||
class="image-upload"
|
||||
aria-disabled=${uploadDisabled}
|
||||
@click=${uploadDisabled ? undefined : this._uploadImageFiles}
|
||||
@@ -434,6 +440,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
this.updateContext({ status: 'success' });
|
||||
reportResponse('aborted:stop');
|
||||
}}
|
||||
data-testid="chat-panel-stop"
|
||||
>
|
||||
${ChatAbortIcon}
|
||||
</div>`
|
||||
|
||||
@@ -14,8 +14,7 @@ import { repeat } from 'lit/directives/repeat.js';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { AffineIcon } from '../_common/icons';
|
||||
import { type AIError, UnauthorizedError } from '../components/ai-item/types';
|
||||
import { AIProvider } from '../provider';
|
||||
import { type AIError, AIProvider, UnauthorizedError } from '../provider';
|
||||
import {
|
||||
type ChatContextValue,
|
||||
type ChatMessage,
|
||||
@@ -31,7 +30,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-panel-messages {
|
||||
.chat-panel-messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
@@ -157,9 +156,16 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor previewSpecBuilder!: SpecBuilder;
|
||||
|
||||
@query('.chat-panel-messages')
|
||||
@query('.chat-panel-messages-container')
|
||||
accessor messagesContainer: HTMLDivElement | null = null;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
attribute: 'data-testid',
|
||||
reflect: true,
|
||||
})
|
||||
accessor testId = 'chat-panel-messages';
|
||||
|
||||
getScrollContainer(): HTMLDivElement | null {
|
||||
return this.messagesContainer;
|
||||
}
|
||||
@@ -168,12 +174,13 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
return this.isLoading ||
|
||||
!this.host?.doc.get(FeatureFlagService).getFlag('enable_ai_onboarding')
|
||||
? nothing
|
||||
: html`<div class="onboarding-wrapper">
|
||||
: html`<div class="onboarding-wrapper" data-testid="ai-onboarding">
|
||||
${repeat(
|
||||
AIPreloadConfig,
|
||||
config => config.text,
|
||||
config => {
|
||||
return html`<div
|
||||
data-testid=${config.testId}
|
||||
@click=${() => config.handler()}
|
||||
class="onboarding-item"
|
||||
>
|
||||
@@ -220,7 +227,8 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="chat-panel-messages"
|
||||
class="chat-panel-messages-container"
|
||||
data-testid="chat-panel-messages-container"
|
||||
@scroll=${() => this._debouncedOnScroll()}
|
||||
>
|
||||
${filteredItems.length === 0
|
||||
@@ -232,8 +240,12 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
)}
|
||||
<div class="messages-placeholder-title" data-loading=${isLoading}>
|
||||
${this.isLoading
|
||||
? 'AFFiNE AI is loading history...'
|
||||
: 'What can I help you with?'}
|
||||
? html`<span data-testid="chat-panel-loading-state"
|
||||
>AFFiNE AI is loading history...</span
|
||||
>`
|
||||
: html`<span data-testid="chat-panel-empty-state"
|
||||
>What can I help you with?</span
|
||||
>`}
|
||||
</div>
|
||||
${this._renderAIOnboarding()}
|
||||
</div> `
|
||||
@@ -268,7 +280,11 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
)}
|
||||
</div>
|
||||
${showDownIndicator && filteredItems.length > 0
|
||||
? html`<div class="down-indicator" @click=${this._onDownIndicatorClick}>
|
||||
? html`<div
|
||||
data-testid="chat-panel-scroll-down-indicator"
|
||||
class="down-indicator"
|
||||
@click=${this._onDownIndicatorClick}
|
||||
>
|
||||
${ArrowDownIcon()}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
@@ -44,6 +44,8 @@ export type MenuItem = {
|
||||
name: string | TemplateResult<1>;
|
||||
icon: TemplateResult<1>;
|
||||
action: MenuAction;
|
||||
suffix?: string | TemplateResult<1>;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export type MenuAction = () => Promise<void> | void;
|
||||
@@ -140,6 +142,7 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
{
|
||||
key: 'tags',
|
||||
name: 'Tags',
|
||||
testId: 'ai-chat-with-tags',
|
||||
icon: TagsIcon(),
|
||||
action: () => {
|
||||
this._toggleMode(AddPopoverMode.Tags);
|
||||
@@ -148,6 +151,7 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
{
|
||||
key: 'collections',
|
||||
name: 'Collections',
|
||||
testId: 'ai-chat-with-collections',
|
||||
icon: CollectionsIcon(),
|
||||
action: () => {
|
||||
this._toggleMode(AddPopoverMode.Collections);
|
||||
@@ -176,6 +180,7 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
{
|
||||
key: 'files',
|
||||
name: 'Upload files (pdf, txt, csv)',
|
||||
testId: 'ai-chat-with-files',
|
||||
icon: UploadIcon(),
|
||||
action: this._addFileChip,
|
||||
},
|
||||
@@ -330,13 +335,14 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
${repeat(
|
||||
items,
|
||||
item => item.key,
|
||||
({ key, name, icon, action }, idx) => {
|
||||
({ key, name, icon, action, testId }, idx) => {
|
||||
const curIdx = startIndex + idx;
|
||||
return html`<icon-button
|
||||
width="280px"
|
||||
height="30px"
|
||||
data-id=${key}
|
||||
data-index=${curIdx}
|
||||
data-testid=${testId}
|
||||
.text=${name}
|
||||
hover=${this._activatedIndex === curIdx}
|
||||
@click=${() => action()?.catch(console.error)}
|
||||
|
||||
@@ -37,6 +37,9 @@ export class ChatContentPureText extends ShadowlessElement {
|
||||
@property({ attribute: false })
|
||||
accessor text: string = '';
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'chat-content-pure-text';
|
||||
|
||||
protected override render() {
|
||||
return this.text.length > 0
|
||||
? html`<div class="chat-content-pure-text">${this.text}</div>`
|
||||
|
||||
@@ -16,6 +16,9 @@ export class ChatMessageAction extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor item!: ChatAction;
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'chat-message-action';
|
||||
|
||||
renderHeader() {
|
||||
return html`
|
||||
<div class="user-info">
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
EdgelessEditorActions,
|
||||
PageEditorActions,
|
||||
} from '../../_common/chat-actions-handle';
|
||||
import { type AIError } from '../../components/ai-item/types';
|
||||
import { AIChatErrorRenderer } from '../../messages/error';
|
||||
import { type AIError } from '../../provider';
|
||||
import { type ChatMessage, isChatMessage } from '../chat-context';
|
||||
|
||||
export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
@@ -34,7 +34,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor isLast: boolean = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
@property({ attribute: 'data-status', reflect: true })
|
||||
accessor status: string = 'idle';
|
||||
|
||||
@property({ attribute: false })
|
||||
@@ -49,6 +49,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor retry!: () => void;
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'chat-message-assistant';
|
||||
|
||||
renderHeader() {
|
||||
const isWithDocs =
|
||||
'content' in this.item &&
|
||||
|
||||
@@ -31,6 +31,9 @@ export class ChatMessageUser extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor item!: ChatMessage;
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'chat-message-user';
|
||||
|
||||
renderContent() {
|
||||
const { item } = this;
|
||||
|
||||
@@ -41,7 +44,7 @@ export class ChatMessageUser extends WithDisposable(ShadowlessElement) {
|
||||
.images=${item.attachments}
|
||||
></chat-content-images>`
|
||||
: nothing}
|
||||
<div class="text-content-wrapper">
|
||||
<div class="text-content-wrapper" data-test-id="chat-content-user-text">
|
||||
<chat-content-pure-text .text=${item.content}></chat-content-pure-text>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user