mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-05 09:04:56 +00:00
Compare commits
11 Commits
v0.25.0
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b84330a9 | ||
|
|
5147e2c62d | ||
|
|
03e8e7143d | ||
|
|
5e8691367d | ||
|
|
e3d88ab3f2 | ||
|
|
61e40c7523 | ||
|
|
cdb721d6a6 | ||
|
|
c89680cb55 | ||
|
|
0256fdb2af | ||
|
|
a4711aad61 | ||
|
|
6d97c5a393 |
@@ -684,7 +684,7 @@
|
||||
},
|
||||
"scenarios": {
|
||||
"type": "object",
|
||||
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4-5@20250929\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
||||
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4@20250514\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
||||
"default": {
|
||||
"override_enabled": false,
|
||||
"scenarios": {
|
||||
@@ -693,7 +693,7 @@
|
||||
"embedding": "gemini-embedding-001",
|
||||
"image": "gpt-image-1",
|
||||
"rerank": "gpt-4.1",
|
||||
"coding": "claude-sonnet-4-5@20250929",
|
||||
"coding": "claude-sonnet-4@20250514",
|
||||
"complex_text_generation": "gpt-4o-2024-08-06",
|
||||
"quick_decision_making": "gpt-5-mini",
|
||||
"quick_text_generation": "gemini-2.5-flash",
|
||||
|
||||
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -161,7 +161,6 @@ dependencies = [
|
||||
"affine_common",
|
||||
"chrono",
|
||||
"file-format",
|
||||
"infer",
|
||||
"mimalloc",
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1505,9 +1504,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "file-format"
|
||||
version = "0.28.0"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eab8aa2fba5f39f494000a22f44bf3c755b7d7f8ffad3f36c6d507893074159"
|
||||
checksum = "e7ef3d5e8ae27277c8285ac43ed153158178ef0f79567f32024ca8140a0c7cd8"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
@@ -1914,7 +1913,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -39,7 +39,7 @@ crossbeam-channel = "0.5"
|
||||
dispatch2 = "0.3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
file-format = { version = "0.26", features = ["reader"] }
|
||||
homedir = "0.3"
|
||||
infer = { version = "0.19.0" }
|
||||
lasso = { version = "0.7", features = ["multi-threaded"] }
|
||||
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -6,12 +6,12 @@ We recommend users to always use the latest major version. Security updates will
|
||||
|
||||
| Version | Supported |
|
||||
| --------------- | ------------------ |
|
||||
| 0.24.x (stable) | :white_check_mark: |
|
||||
| < 0.24.x | :x: |
|
||||
| 0.17.x (stable) | :white_check_mark: |
|
||||
| < 0.17.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info) or submit directly on [GitHub](https://github.com/toeverything/AFFiNE/security), **we encourage you to submit the relevant information directly via GitHub**. We expect your report to contain at least the following for us to evaluate and reproduce:
|
||||
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info). We expect your report to contain at least the following for us to evaluate and reproduce:
|
||||
|
||||
1. Using platform and version, for example:
|
||||
|
||||
@@ -22,6 +22,8 @@ We welcome you to provide us with bug reports via and email at [security@toevery
|
||||
|
||||
3. Your classification or analysis of the vulnerability (optional)
|
||||
|
||||
Since we are an open source project, we also welcome you to provide corresponding fix PRs, we will determine specific rewards based on the evaluation results.
|
||||
Since we are an open source project, we also welcome you to provide corresponding fix PRs.
|
||||
|
||||
We will provide bounties for vulnerabilities involving user information leakage, permission leakage, and unauthorized code execution. For other types of vulnerabilities, we will determine specific rewards based on the evaluation results.
|
||||
|
||||
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@affine/component": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||
@@ -22,7 +23,6 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
export const calloutHostStyles = css({
|
||||
display: 'block',
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
export const calloutBlockContainerStyles = css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '5px 10px',
|
||||
borderRadius: '8px',
|
||||
});
|
||||
|
||||
export const calloutEmojiContainerStyles = css({
|
||||
userSelect: 'none',
|
||||
fontSize: '1.2em',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// marginTop is dynamically set by JavaScript based on first child's height
|
||||
marginBottom: '10px',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const calloutEmojiStyles = css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
export const calloutChildrenStyles = css({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingLeft: '10px',
|
||||
});
|
||||
|
||||
export const iconPickerContainerStyles = css({
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
background: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
width: '390px',
|
||||
height: '400px',
|
||||
});
|
||||
@@ -1,13 +1,6 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import {
|
||||
createPopup,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import {
|
||||
type CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { type CalloutBlockModel, DefaultTheme } from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
@@ -15,24 +8,15 @@ import {
|
||||
type IconData,
|
||||
IconPickerServiceIdentifier,
|
||||
IconType,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { UniComponent } from '@blocksuite/affine-shared/types';
|
||||
import * as icons from '@blocksuite/icons/lit';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { type Signal } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { type Signal, signal } from '@preact/signals-core';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { css, html } from 'lit';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
calloutBlockContainerStyles,
|
||||
calloutChildrenStyles,
|
||||
calloutEmojiContainerStyles,
|
||||
calloutEmojiStyles,
|
||||
calloutHostStyles,
|
||||
} from './callout-block-styles.js';
|
||||
import { IconPickerWrapper } from './icon-picker-wrapper.js';
|
||||
// Copy of renderUniLit and UniLit from affine-data-view
|
||||
export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
|
||||
uni: UniComponent<Props, Expose> | undefined,
|
||||
@@ -51,8 +35,9 @@ export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
|
||||
></uni-lit>`;
|
||||
};
|
||||
const getIcon = (icon?: IconData) => {
|
||||
console.log(icon);
|
||||
if (!icon) {
|
||||
return null;
|
||||
return '💡';
|
||||
}
|
||||
if (icon.type === IconType.Emoji) {
|
||||
return icon.unicode;
|
||||
@@ -62,64 +47,85 @@ const getIcon = (icon?: IconData) => {
|
||||
icons as Record<string, (props: { style: string }) => TemplateResult>
|
||||
)[`${icon.name}Icon`]?.({ style: `color:${icon.color}` });
|
||||
}
|
||||
return null;
|
||||
return '💡';
|
||||
};
|
||||
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
|
||||
private _popupCloseHandler: (() => void) | null = null;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(calloutHostStyles);
|
||||
}
|
||||
|
||||
private _getEmojiMarginTop(): string {
|
||||
if (this.model.children.length === 0) {
|
||||
return '10px';
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
const firstChild = this.model.children[0];
|
||||
const flavour = firstChild.flavour;
|
||||
|
||||
const marginTopMap: Record<string, string> = {
|
||||
'affine:paragraph:h1': '23px',
|
||||
'affine:paragraph:h2': '20px',
|
||||
'affine:paragraph:h3': '16px',
|
||||
'affine:paragraph:h4': '15px',
|
||||
'affine:paragraph:h5': '14px',
|
||||
'affine:paragraph:h6': '13px',
|
||||
};
|
||||
|
||||
// For heading blocks, use the type to determine margin
|
||||
if (flavour === 'affine:paragraph') {
|
||||
const paragraph = firstChild as ParagraphBlockModel;
|
||||
const type = paragraph.props.type$.value;
|
||||
const key = `${flavour}:${type}`;
|
||||
return marginTopMap[key] || '10px';
|
||||
.affine-callout-block-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// Default for all other block types
|
||||
return '10px';
|
||||
}
|
||||
|
||||
private _closeIconPicker() {
|
||||
if (this._popupCloseHandler) {
|
||||
this._popupCloseHandler();
|
||||
this._popupCloseHandler = null;
|
||||
.affine-callout-emoji-container {
|
||||
user-select: none;
|
||||
font-size: 1.2em;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.affine-callout-emoji {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.affine-callout-emoji:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.affine-callout-children {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.icon-picker-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly showIconPicker$ = signal(false);
|
||||
|
||||
private _closeEmojiMenu() {
|
||||
this.showIconPicker$.value = false;
|
||||
}
|
||||
|
||||
private _toggleIconPicker(event: MouseEvent) {
|
||||
// If popup is already open, close it
|
||||
if (this._popupCloseHandler) {
|
||||
this._closeIconPicker();
|
||||
return;
|
||||
private _toggleIconPicker() {
|
||||
this.showIconPicker$.value = !this.showIconPicker$.value;
|
||||
}
|
||||
|
||||
private _renderIconPicker() {
|
||||
if (!this.showIconPicker$.value) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
// Get IconPickerService from the framework
|
||||
const iconPickerService = this.std.getOptional(IconPickerServiceIdentifier);
|
||||
if (!iconPickerService) {
|
||||
console.warn('IconPickerService not found');
|
||||
return;
|
||||
return html``;
|
||||
}
|
||||
|
||||
// Get the uni-component from the service
|
||||
@@ -129,31 +135,23 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
const props = {
|
||||
onSelect: (iconData?: IconData) => {
|
||||
this.model.props.icon$.value = iconData;
|
||||
this._closeIconPicker(); // Close the picker after selection
|
||||
this._closeEmojiMenu(); // Close the picker after selection
|
||||
},
|
||||
onClose: () => {
|
||||
this._closeIconPicker();
|
||||
this._closeEmojiMenu();
|
||||
},
|
||||
};
|
||||
|
||||
// Create IconPickerWrapper instance
|
||||
const wrapper = new IconPickerWrapper();
|
||||
wrapper.iconPickerComponent = iconPickerComponent;
|
||||
wrapper.props = props;
|
||||
wrapper.style.position = 'absolute';
|
||||
wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel;
|
||||
wrapper.style.boxShadow = 'var(--affine-menu-shadow)';
|
||||
wrapper.style.borderRadius = '8px';
|
||||
|
||||
// Create popup target from the clicked element
|
||||
const target = popupTargetFromElement(event.currentTarget as HTMLElement);
|
||||
|
||||
// Create popup
|
||||
this._popupCloseHandler = createPopup(target, wrapper, {
|
||||
onClose: () => {
|
||||
this._popupCloseHandler = null;
|
||||
},
|
||||
});
|
||||
return html`
|
||||
<div
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
class="icon-picker-container"
|
||||
>
|
||||
${renderUniLit(iconPickerComponent, props)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private readonly _handleBlockClick = (event: MouseEvent) => {
|
||||
@@ -166,13 +164,6 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's no icon, open icon picker on click
|
||||
const icon = this.model.props.icon$.value;
|
||||
if (!icon) {
|
||||
this._toggleIconPicker(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle clicks when there are no children
|
||||
if (this.model.children.length > 0) {
|
||||
return;
|
||||
@@ -215,38 +206,33 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
|
||||
override renderBlock() {
|
||||
const icon = this.model.props.icon$.value;
|
||||
const backgroundColorName = this.model.props.backgroundColorName$.value;
|
||||
const backgroundColor = (
|
||||
cssVarV2.block.callout.background as Record<string, string>
|
||||
)[backgroundColorName ?? ''];
|
||||
const background = this.model.props.background$.value;
|
||||
|
||||
const iconContent = getIcon(icon);
|
||||
const themeProvider = this.std.get(ThemeProvider);
|
||||
const theme = themeProvider.theme$.value;
|
||||
const backgroundColor = themeProvider.generateColorProperty(
|
||||
background || DefaultTheme.NoteBackgroundColorMap.White,
|
||||
DefaultTheme.NoteBackgroundColorMap.White,
|
||||
theme
|
||||
);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="${calloutBlockContainerStyles}"
|
||||
class="affine-callout-block-container"
|
||||
@click=${this._handleBlockClick}
|
||||
style=${styleMap({
|
||||
backgroundColor: backgroundColor ?? 'transparent',
|
||||
backgroundColor: backgroundColor,
|
||||
})}
|
||||
>
|
||||
${iconContent
|
||||
? html`
|
||||
<div
|
||||
@click=${this._toggleIconPicker}
|
||||
contenteditable="false"
|
||||
class="${calloutEmojiContainerStyles}"
|
||||
style=${styleMap({
|
||||
marginTop: this._getEmojiMarginTop(),
|
||||
})}
|
||||
>
|
||||
<span class="${calloutEmojiStyles}" data-testid="callout-emoji"
|
||||
>${iconContent}</span
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
<div class="${calloutChildrenStyles}">
|
||||
<div
|
||||
@click=${this._toggleIconPicker}
|
||||
contenteditable="false"
|
||||
class="affine-callout-emoji-container"
|
||||
>
|
||||
<span class="affine-callout-emoji">${getIcon(icon)}</span>
|
||||
${this._renderIconPicker()}
|
||||
</div>
|
||||
<div class="affine-callout-children">
|
||||
${this.renderChildren(this.model)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import {
|
||||
createPopup,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import { CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import { CalloutBlockModel, DefaultTheme } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type IconData,
|
||||
IconPickerServiceIdentifier,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { DeleteIcon, PaletteIcon, SmileIcon } from '@blocksuite/icons/lit';
|
||||
import { PaletteIcon } from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { html } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { IconPickerWrapper } from '../icon-picker-wrapper.js';
|
||||
|
||||
const colors = [
|
||||
'default',
|
||||
'red',
|
||||
@@ -48,7 +38,27 @@ const backgroundColorAction = {
|
||||
if (!model) return null;
|
||||
|
||||
const updateBackground = (color: string) => {
|
||||
ctx.store.updateBlock(model, { backgroundColorName: color });
|
||||
// Map text highlight colors to note background colors
|
||||
const colorMap: Record<
|
||||
string,
|
||||
keyof typeof DefaultTheme.NoteBackgroundColorMap | null
|
||||
> = {
|
||||
default: null,
|
||||
red: 'Red',
|
||||
orange: 'Orange',
|
||||
yellow: 'Yellow',
|
||||
green: 'Green',
|
||||
teal: 'Green', // Map teal to green as it's not available in NoteBackgroundColorMap
|
||||
blue: 'Blue',
|
||||
purple: 'Purple',
|
||||
grey: 'White', // Map grey to white as it's the closest available
|
||||
};
|
||||
|
||||
const mappedColor = colorMap[color];
|
||||
const backgroundValue = mappedColor
|
||||
? DefaultTheme.NoteBackgroundColorMap[mappedColor]
|
||||
: null;
|
||||
ctx.store.updateBlock(model, { background: backgroundValue });
|
||||
};
|
||||
|
||||
return html`
|
||||
@@ -93,102 +103,12 @@ const backgroundColorAction = {
|
||||
},
|
||||
} satisfies ToolbarAction;
|
||||
|
||||
const iconPickerAction = {
|
||||
id: 'icon-picker',
|
||||
label: 'Icon Picker',
|
||||
tooltip: 'Change icon',
|
||||
icon: SmileIcon(),
|
||||
run() {
|
||||
// This will be handled by the content function
|
||||
},
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(CalloutBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const handleIconPickerClick = (event: MouseEvent) => {
|
||||
// Get IconPickerService from the framework
|
||||
const iconPickerService = ctx.std.getOptional(
|
||||
IconPickerServiceIdentifier
|
||||
);
|
||||
if (!iconPickerService) {
|
||||
console.warn('IconPickerService not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the uni-component from the service
|
||||
const iconPickerComponent = iconPickerService.iconPickerComponent;
|
||||
|
||||
// Create props for the icon picker
|
||||
const props = {
|
||||
onSelect: (iconData?: IconData) => {
|
||||
// When iconData is undefined (delete icon), set icon to undefined
|
||||
ctx.store.updateBlock(model, { icon: iconData });
|
||||
closeHandler(); // Close the picker after selection
|
||||
},
|
||||
onClose: () => {
|
||||
closeHandler();
|
||||
},
|
||||
};
|
||||
|
||||
// Create IconPickerWrapper instance
|
||||
const wrapper = new IconPickerWrapper();
|
||||
wrapper.iconPickerComponent = iconPickerComponent;
|
||||
wrapper.props = props;
|
||||
wrapper.style.position = 'absolute';
|
||||
wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel;
|
||||
wrapper.style.boxShadow = 'var(--affine-menu-shadow)';
|
||||
wrapper.style.borderRadius = '8px';
|
||||
|
||||
// Create popup target from the clicked element
|
||||
const target = popupTargetFromElement(event.currentTarget as HTMLElement);
|
||||
|
||||
// Create popup
|
||||
const closeHandler = createPopup(target, wrapper, {
|
||||
onClose: () => {
|
||||
// Cleanup if needed
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return html`
|
||||
<editor-icon-button
|
||||
aria-label="icon-picker"
|
||||
.tooltip=${'Change Icon'}
|
||||
@click=${handleIconPickerClick}
|
||||
>
|
||||
${SmileIcon()} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
},
|
||||
} satisfies ToolbarAction;
|
||||
|
||||
const builtinToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'style',
|
||||
actions: [backgroundColorAction],
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'icon',
|
||||
actions: [iconPickerAction],
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(CalloutBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
} satisfies ToolbarAction,
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { CalloutBlockComponent } from './callout-block';
|
||||
import { IconPickerWrapper } from './icon-picker-wrapper';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-callout', CalloutBlockComponent);
|
||||
customElements.define('icon-picker-wrapper', IconPickerWrapper);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-callout': CalloutBlockComponent;
|
||||
'icon-picker-wrapper': IconPickerWrapper;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { IconData } from '@blocksuite/affine-shared/services';
|
||||
import type { UniComponent } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { type Signal } from '@preact/signals-core';
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
// Copy of renderUniLit from callout-block.ts
|
||||
const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
|
||||
uni: UniComponent<Props, Expose> | undefined,
|
||||
props?: Props,
|
||||
options?: {
|
||||
ref?: Signal<Expose | undefined>;
|
||||
style?: Readonly<StyleInfo>;
|
||||
class?: string;
|
||||
}
|
||||
): TemplateResult => {
|
||||
return html` <uni-lit
|
||||
.uni="${uni}"
|
||||
.props="${props}"
|
||||
.ref="${options?.ref}"
|
||||
style=${options?.style ? styleMap(options?.style) : ''}
|
||||
></uni-lit>`;
|
||||
};
|
||||
|
||||
export interface IconPickerWrapperProps {
|
||||
onSelect?: (iconData?: IconData) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export class IconPickerWrapper extends ShadowlessElement {
|
||||
iconPickerComponent?: UniComponent<IconPickerWrapperProps, any>;
|
||||
props?: IconPickerWrapperProps;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.iconPickerComponent) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return renderUniLit(this.iconPickerComponent, this.props);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'icon-picker-wrapper': IconPickerWrapper;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { updateBlockAlign } from '@blocksuite/affine-block-note';
|
||||
import { ImageBlockModel, TextAlign } from '@blocksuite/affine-model';
|
||||
import { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
@@ -13,9 +12,6 @@ import {
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
@@ -55,55 +51,7 @@ const builtinToolbarConfig = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.1.align-left',
|
||||
tooltip: 'Align left',
|
||||
icon: TextAlignLeftIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
|
||||
if (block) {
|
||||
ctx.chain
|
||||
.pipe(updateBlockAlign, {
|
||||
textAlign: TextAlign.Left,
|
||||
selectedBlocks: [block],
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.2.align-center',
|
||||
tooltip: 'Align center',
|
||||
icon: TextAlignCenterIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
|
||||
if (block) {
|
||||
ctx.chain
|
||||
.pipe(updateBlockAlign, {
|
||||
textAlign: TextAlign.Center,
|
||||
selectedBlocks: [block],
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.3.align-right',
|
||||
tooltip: 'Align right',
|
||||
icon: TextAlignRightIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
|
||||
if (block) {
|
||||
ctx.chain
|
||||
.pipe(updateBlockAlign, {
|
||||
textAlign: TextAlign.Right,
|
||||
selectedBlocks: [block],
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'd.comment',
|
||||
id: 'c.comment',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,15 +143,6 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
const alignItemsStyleMap = styleMap({
|
||||
alignItems:
|
||||
this.model.props.textAlign$.value === 'left'
|
||||
? 'flex-start'
|
||||
: this.model.props.textAlign$.value === 'right'
|
||||
? 'flex-end'
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const resovledState = this.resourceController.resolveStateWith({
|
||||
loadingIcon: LoadingIcon({
|
||||
strokeColor: cssVarV2('button/pureWhiteText'),
|
||||
@@ -171,7 +162,6 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
|
||||
html`<affine-page-image
|
||||
.block=${this}
|
||||
.state=${resovledState}
|
||||
style="${alignItemsStyleMap}"
|
||||
></affine-page-image>`,
|
||||
() =>
|
||||
html`<affine-image-fallback-card
|
||||
|
||||
@@ -150,10 +150,6 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
|
||||
|
||||
const listIcon = getListIcon(model, !collapsed, _onClickIcon);
|
||||
|
||||
const textAlignStyle = styleMap({
|
||||
textAlign: this.model.props.textAlign$?.value,
|
||||
});
|
||||
|
||||
const children = html`<div
|
||||
class="affine-block-children-container"
|
||||
style=${styleMap({
|
||||
@@ -165,7 +161,7 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
|
||||
</div>`;
|
||||
|
||||
return html`
|
||||
<div class=${'affine-list-block-container'} style="${textAlignStyle}">
|
||||
<div class=${'affine-list-block-container'}>
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-list-rich-text-wrapper': true,
|
||||
|
||||
@@ -8,4 +8,3 @@ export { indentBlock } from './indent-block.js';
|
||||
export { indentBlocks } from './indent-blocks.js';
|
||||
export { selectBlock } from './select-block.js';
|
||||
export { selectBlocksBetween } from './select-blocks-between.js';
|
||||
export { updateBlockAlign } from './update-block-align.js';
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { TextAlign } from '@blocksuite/affine-model';
|
||||
import {
|
||||
getBlockSelectionsCommand,
|
||||
getImageSelectionsCommand,
|
||||
getSelectedBlocksCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type Command,
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
|
||||
type UpdateBlockAlignConfig = {
|
||||
textAlign: TextAlign;
|
||||
selectedBlocks?: BlockComponent[];
|
||||
};
|
||||
|
||||
export const updateBlockAlign: Command<UpdateBlockAlignConfig> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
let { std, textAlign, selectedBlocks } = ctx;
|
||||
|
||||
if (selectedBlocks === null) {
|
||||
const [result, ctx] = std.command
|
||||
.chain()
|
||||
.tryAll(chain => [
|
||||
chain.pipe(getTextSelectionCommand),
|
||||
chain.pipe(getBlockSelectionsCommand),
|
||||
chain.pipe(getImageSelectionsCommand),
|
||||
])
|
||||
.pipe(getSelectedBlocksCommand, { types: ['text', 'block', 'image'] })
|
||||
.run();
|
||||
if (result) {
|
||||
selectedBlocks = ctx.selectedBlocks;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedBlocks || selectedBlocks.length === 0) return false;
|
||||
|
||||
selectedBlocks.forEach(block => {
|
||||
std.store.updateBlock(block.model, { textAlign });
|
||||
});
|
||||
|
||||
const selectionManager = std.host.selection;
|
||||
const textSelection = selectionManager.find(TextSelection);
|
||||
if (!textSelection) {
|
||||
return false;
|
||||
}
|
||||
selectionManager.setGroup('note', [textSelection]);
|
||||
return next();
|
||||
};
|
||||
@@ -4,15 +4,9 @@ import {
|
||||
textFormatConfigs,
|
||||
} from '@blocksuite/affine-inline-preset';
|
||||
import {
|
||||
type TextAlignConfig,
|
||||
textAlignConfigs,
|
||||
type TextConversionConfig,
|
||||
textConversionConfigs,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
getSelectedModelsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type SlashMenuActionItem,
|
||||
@@ -23,7 +17,7 @@ import {
|
||||
import { HeadingsIcon } from '@blocksuite/icons/lit';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
|
||||
import { updateBlockAlign, updateBlockType } from '../commands';
|
||||
import { updateBlockType } from '../commands';
|
||||
import { tooltips } from './tooltips';
|
||||
|
||||
let basicIndex = 0;
|
||||
@@ -66,10 +60,6 @@ const noteSlashMenuConfig: SlashMenuConfig = {
|
||||
createConversionItem(config, `1_List@${index++}`)
|
||||
),
|
||||
|
||||
...textAlignConfigs.map((config, index) =>
|
||||
createAlignItem(config, `2_Align@${index++}`)
|
||||
),
|
||||
|
||||
...textFormatConfigs
|
||||
.filter(i => !['Code', 'Link'].includes(i.name))
|
||||
.map((config, index) =>
|
||||
@@ -99,26 +89,6 @@ function createConversionItem(
|
||||
};
|
||||
}
|
||||
|
||||
function createAlignItem(
|
||||
config: TextAlignConfig,
|
||||
group?: SlashMenuItem['group']
|
||||
): SlashMenuActionItem {
|
||||
const { textAlign, name, icon } = config;
|
||||
return {
|
||||
name,
|
||||
group,
|
||||
icon,
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getTextSelectionCommand)
|
||||
.pipe(getSelectedModelsCommand, { types: ['text'] })
|
||||
.pipe(updateBlockAlign, { textAlign })
|
||||
.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTextFormatItem(
|
||||
config: TextFormatConfig,
|
||||
group?: SlashMenuItem['group']
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
NoteBlockSchema,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
textAlignConfigs,
|
||||
textConversionConfigs,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { textConversionConfigs } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
focusBlockEnd,
|
||||
focusBlockStart,
|
||||
@@ -39,7 +36,6 @@ import {
|
||||
indentBlocks,
|
||||
selectBlock,
|
||||
selectBlocksBetween,
|
||||
updateBlockAlign,
|
||||
updateBlockType,
|
||||
} from './commands';
|
||||
import { moveBlockConfigs } from './move-block';
|
||||
@@ -161,36 +157,6 @@ class NoteKeymap {
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _bindTextAlignHotKey = () => {
|
||||
return textAlignConfigs.reduce(
|
||||
(acc, item) => {
|
||||
const keymap = item.hotkey!.reduce(
|
||||
(acc, key) => {
|
||||
return {
|
||||
...acc,
|
||||
[key]: ctx => {
|
||||
ctx.get('defaultState').event.preventDefault();
|
||||
const [result] = this._std.command
|
||||
.chain()
|
||||
.pipe(updateBlockAlign, { textAlign: item.textAlign })
|
||||
.run();
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
},
|
||||
{} as Record<string, UIEventHandler>
|
||||
);
|
||||
|
||||
return {
|
||||
...acc,
|
||||
...keymap,
|
||||
};
|
||||
},
|
||||
{} as Record<string, UIEventHandler>
|
||||
);
|
||||
};
|
||||
|
||||
private _focusBlock: BlockComponent | null = null;
|
||||
|
||||
private readonly _getClosestNoteByBlockId = (blockId: string) => {
|
||||
@@ -602,7 +568,6 @@ class NoteKeymap {
|
||||
...this._bindMoveBlockHotKey(),
|
||||
...this._bindQuickActionHotKey(),
|
||||
...this._bindTextConversionHotKey(),
|
||||
...this._bindTextAlignHotKey(),
|
||||
Tab: ctx => {
|
||||
const [success] = this.std.command.exec(indentBlocks);
|
||||
|
||||
|
||||
@@ -264,10 +264,6 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
|
||||
`;
|
||||
}
|
||||
|
||||
const textAlignStyle = styleMap({
|
||||
textAlign: this.model.props.textAlign$?.value,
|
||||
});
|
||||
|
||||
const children = html`<div
|
||||
class="affine-block-children-container"
|
||||
style=${styleMap({
|
||||
@@ -292,7 +288,6 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
|
||||
'affine-paragraph-block-container': true,
|
||||
'highlight-comment': this.isCommentHighlighted,
|
||||
})}
|
||||
style="${textAlignStyle}"
|
||||
data-has-collapsed-siblings="${collapsedSiblings.length > 0}"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import {
|
||||
updateBlockAlign,
|
||||
updateBlockType,
|
||||
} from '@blocksuite/affine-block-note';
|
||||
import { updateBlockType } from '@blocksuite/affine-block-note';
|
||||
import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
@@ -26,12 +23,8 @@ import {
|
||||
import {
|
||||
EmbedLinkedDocBlockSchema,
|
||||
EmbedSyncedDocBlockSchema,
|
||||
type TextAlign,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
textAlignConfigs,
|
||||
textConversionConfigs,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { textConversionConfigs } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
copySelectedModelsCommand,
|
||||
deleteSelectedModelsCommand,
|
||||
@@ -53,7 +46,6 @@ import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
|
||||
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
|
||||
import {
|
||||
CopyIcon,
|
||||
@@ -138,64 +130,6 @@ const conversionsActionGroup = {
|
||||
},
|
||||
} as const satisfies ToolbarActionGenerator;
|
||||
|
||||
const alignActionGroup = {
|
||||
id: 'b.align',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
generate({ chain }) {
|
||||
const [ok, { selectedModels = [] }] = chain
|
||||
.tryAll(chain => [
|
||||
chain.pipe(getTextSelectionCommand),
|
||||
chain.pipe(getBlockSelectionsCommand),
|
||||
])
|
||||
.pipe(getSelectedModelsCommand, { types: ['text', 'block'] })
|
||||
.run();
|
||||
if (!ok) return null;
|
||||
|
||||
const alignment =
|
||||
textAlignConfigs.find(
|
||||
({ textAlign }) =>
|
||||
textAlign ===
|
||||
getMostCommonValue(
|
||||
selectedModels.map(
|
||||
({ props }) => props as { textAlign?: TextAlign }
|
||||
),
|
||||
'textAlign'
|
||||
)
|
||||
) ?? textAlignConfigs[0];
|
||||
const update = (textAlign: TextAlign) => {
|
||||
chain.pipe(updateBlockAlign, { textAlign }).run();
|
||||
};
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Align" .tooltip="${'Align'}">
|
||||
${alignment.icon} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${repeat(
|
||||
textAlignConfigs,
|
||||
item => item.name,
|
||||
({ textAlign, name, icon }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${name}
|
||||
@click=${() => update(textAlign)}
|
||||
>
|
||||
${icon}<span class="label">${name}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
};
|
||||
},
|
||||
} as const satisfies ToolbarActionGenerator;
|
||||
|
||||
const inlineTextActionGroup = {
|
||||
id: 'b.inline-text',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
@@ -357,7 +291,6 @@ const turnIntoLinkedDoc = {
|
||||
export const builtinToolbarConfig = {
|
||||
actions: [
|
||||
conversionsActionGroup,
|
||||
alignActionGroup,
|
||||
inlineTextActionGroup,
|
||||
highlightActionGroup,
|
||||
turnIntoDatabase,
|
||||
|
||||
@@ -144,16 +144,6 @@ export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel
|
||||
style=${styleMap({
|
||||
paddingLeft: `${virtualPadding}px`,
|
||||
paddingRight: `${virtualPadding}px`,
|
||||
marginLeft:
|
||||
!this.model.props.textAlign$.value ||
|
||||
this.model.props.textAlign$?.value === 'left'
|
||||
? undefined
|
||||
: 'auto',
|
||||
marginRight:
|
||||
!this.model.props.textAlign$.value ||
|
||||
this.model.props.textAlign$?.value === 'right'
|
||||
? undefined
|
||||
: 'auto',
|
||||
width: 'max-content',
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -19,16 +19,16 @@ const DOC_BLOCK_CHILD_PADDING = 24;
|
||||
|
||||
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.doc-title-container {
|
||||
font-size: 40px;
|
||||
line-height: 50px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.doc-icon-container,
|
||||
.doc-title-container {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 40px;
|
||||
line-height: 50px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
resize: none;
|
||||
border: 0;
|
||||
@@ -47,10 +47,6 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
${DOC_BLOCK_CHILD_PADDING}px
|
||||
);
|
||||
}
|
||||
.doc-icon-container + * .doc-title-container {
|
||||
/* when doc icon exists, remove the top padding */
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Extra small devices (phones, 640px and down) */
|
||||
@container viewport (width <= 640px) {
|
||||
|
||||
@@ -6,20 +6,22 @@ import {
|
||||
type Text,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { Color } from '../../themes/index.js';
|
||||
import { DefaultTheme } from '../../themes/index.js';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
export type CalloutProps = {
|
||||
icon?: IconData;
|
||||
text: Text;
|
||||
backgroundColorName?: string;
|
||||
background: Color;
|
||||
} & BlockMeta;
|
||||
|
||||
export const CalloutBlockSchema = defineBlockSchema({
|
||||
flavour: 'affine:callout',
|
||||
props: (internal): CalloutProps => ({
|
||||
icon: { type: 'emoji', unicode: '💡' } as IconData,
|
||||
icon: undefined,
|
||||
text: internal.Text(),
|
||||
backgroundColorName: 'grey',
|
||||
background: DefaultTheme.NoteBackgroundColorMap.White,
|
||||
'meta:createdAt': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types.js';
|
||||
import { ImageBlockTransformer } from './image-transformer.js';
|
||||
|
||||
@@ -21,7 +20,6 @@ export type ImageBlockProps = {
|
||||
rotate: number;
|
||||
size?: number;
|
||||
comments?: Record<string, boolean>;
|
||||
textAlign?: TextAlign;
|
||||
} & Omit<GfxCommonBlockProps, 'scale'> &
|
||||
BlockMeta;
|
||||
|
||||
@@ -36,7 +34,6 @@ const defaultImageProps: ImageBlockProps = {
|
||||
rotate: 0,
|
||||
size: -1,
|
||||
comments: undefined,
|
||||
textAlign: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
// `toggle` type has been deprecated, do not use it
|
||||
@@ -14,7 +13,6 @@ export type ListType = 'bulleted' | 'numbered' | 'todo' | 'toggle';
|
||||
export type ListProps = {
|
||||
type: ListType;
|
||||
text: Text;
|
||||
textAlign?: TextAlign;
|
||||
checked: boolean;
|
||||
collapsed: boolean;
|
||||
order: number | null;
|
||||
@@ -27,7 +25,6 @@ export const ListBlockSchema = defineBlockSchema({
|
||||
({
|
||||
type: 'bulleted',
|
||||
text: internal.Text(),
|
||||
textAlign: undefined,
|
||||
checked: false,
|
||||
collapsed: false,
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type Text,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
export type ParagraphType =
|
||||
@@ -20,7 +19,6 @@ export type ParagraphType =
|
||||
|
||||
export type ParagraphProps = {
|
||||
type: ParagraphType;
|
||||
textAlign?: TextAlign;
|
||||
text: Text;
|
||||
collapsed: boolean;
|
||||
comments?: Record<string, boolean>;
|
||||
@@ -31,7 +29,6 @@ export const ParagraphBlockSchema = defineBlockSchema({
|
||||
props: (internal): ParagraphProps => ({
|
||||
type: 'text',
|
||||
text: internal.Text(),
|
||||
textAlign: undefined,
|
||||
collapsed: false,
|
||||
comments: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
export type TableCell = {
|
||||
@@ -31,7 +30,6 @@ export interface TableBlockProps extends BlockMeta {
|
||||
// key = `${rowId}:${columnId}`
|
||||
cells: Record<string, TableCell>;
|
||||
comments?: Record<string, boolean>;
|
||||
textAlign?: TextAlign;
|
||||
}
|
||||
|
||||
export interface TableCellSerialized {
|
||||
@@ -55,7 +53,6 @@ export const TableBlockSchema = defineBlockSchema({
|
||||
columns: {},
|
||||
cells: {},
|
||||
comments: undefined,
|
||||
textAlign: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { TextAlign } from '@blocksuite/affine-model';
|
||||
import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export interface TextAlignConfig {
|
||||
textAlign: TextAlign;
|
||||
name: string;
|
||||
hotkey: string[] | null;
|
||||
icon: TemplateResult<1>;
|
||||
}
|
||||
|
||||
export const textAlignConfigs: TextAlignConfig[] = [
|
||||
{
|
||||
textAlign: TextAlign.Left,
|
||||
name: 'Align left',
|
||||
hotkey: [`Mod-Shift-L`],
|
||||
icon: TextAlignLeftIcon(),
|
||||
},
|
||||
{
|
||||
textAlign: TextAlign.Center,
|
||||
name: 'Align center',
|
||||
hotkey: [`Mod-Shift-E`],
|
||||
icon: TextAlignCenterIcon(),
|
||||
},
|
||||
{
|
||||
textAlign: TextAlign.Right,
|
||||
name: 'Align right',
|
||||
hotkey: [`Mod-Shift-R`],
|
||||
icon: TextAlignRightIcon(),
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,3 @@
|
||||
export { type TextAlignConfig, textAlignConfigs } from './align';
|
||||
export { type TextConversionConfig, textConversionConfigs } from './conversion';
|
||||
export {
|
||||
asyncGetRichText,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { UniComponent } from '@blocksuite/affine-shared/types';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { TemplateResult } from 'lit';
|
||||
export enum IconType {
|
||||
Emoji = 'emoji',
|
||||
AffineIcon = 'affine-icon',
|
||||
@@ -21,8 +22,15 @@ export type IconData =
|
||||
blob: Blob;
|
||||
};
|
||||
|
||||
export interface IconPickerOptions {
|
||||
onSelect?: (icon: IconData) => void;
|
||||
onClose?: () => void;
|
||||
currentIcon?: IconData;
|
||||
}
|
||||
|
||||
export interface IconPickerService {
|
||||
iconPickerComponent: UniComponent<{ onSelect?: (data?: IconData) => void }>;
|
||||
renderIconPicker(options: IconPickerOptions): TemplateResult;
|
||||
}
|
||||
|
||||
export const IconPickerServiceIdentifier =
|
||||
|
||||
@@ -76,16 +76,10 @@ export const linkedDocPopoverStyles = css`
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
|
||||
.group icon-button svg,
|
||||
.group icon-button .icon {
|
||||
.group icon-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.group icon-button .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group {
|
||||
display: flex;
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.6.8",
|
||||
"oxlint": "~1.18.0",
|
||||
"oxlint": "^1.15.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semver": "^7.6.3",
|
||||
"serve": "^14.2.4",
|
||||
|
||||
@@ -10,7 +10,6 @@ crate-type = ["cdylib"]
|
||||
affine_common = { workspace = true, features = ["doc-loader"] }
|
||||
chrono = { workspace = true }
|
||||
file-format = { workspace = true }
|
||||
infer = { workspace = true }
|
||||
napi = { workspace = true, features = ["async"] }
|
||||
napi-derive = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -2,11 +2,7 @@ use napi_derive::napi;
|
||||
|
||||
#[napi]
|
||||
pub fn get_mime(input: &[u8]) -> String {
|
||||
if let Some(kind) = infer::get(&input[..4096.min(input.len())]) {
|
||||
kind.mime_type().to_string()
|
||||
} else {
|
||||
file_format::FileFormat::from_bytes(input)
|
||||
.media_type()
|
||||
.to_string()
|
||||
}
|
||||
file_format::FileFormat::from_bytes(input)
|
||||
.media_type()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -473,7 +473,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
> should honor requested pro model during active
|
||||
|
||||
'claude-sonnet-4-5@20250929'
|
||||
'claude-sonnet-4@20250514'
|
||||
|
||||
> should fallback to default model when requesting non-optional model during active
|
||||
|
||||
|
||||
Binary file not shown.
@@ -30,7 +30,6 @@ import {
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
inviteUser,
|
||||
smallestPng,
|
||||
TestingApp,
|
||||
TestUser,
|
||||
} from './utils';
|
||||
@@ -454,6 +453,8 @@ test('should create message correctly', async t => {
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
@@ -474,6 +475,8 @@ test('should create message correctly', async t => {
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
|
||||
@@ -2074,11 +2074,11 @@ test('should resolve model correctly based on subscription status and prompt con
|
||||
messages: {
|
||||
create: [{ idx: 0, role: 'system', content: 'test' }],
|
||||
},
|
||||
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'] },
|
||||
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'] },
|
||||
optionalModels: [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'claude-sonnet-4-5@20250929',
|
||||
'claude-sonnet-4@20250514',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -2138,7 +2138,7 @@ test('should resolve model correctly based on subscription status and prompt con
|
||||
'should pick default model when no requested model during active'
|
||||
);
|
||||
|
||||
const model7 = await s.resolveModel(true, 'claude-sonnet-4-5@20250929');
|
||||
const model7 = await s.resolveModel(true, 'claude-sonnet-4@20250514');
|
||||
t.snapshot(model7, 'should honor requested pro model during active');
|
||||
|
||||
const model8 = await s.resolveModel(true, 'not-in-optional');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import { PrismaClient, User } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import { omit } from 'lodash-es';
|
||||
import Sinon from 'sinon';
|
||||
@@ -14,7 +14,6 @@ import { Models } from '../../models';
|
||||
import { PaymentModule } from '../../plugins/payment';
|
||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||
import { UserSubscriptionManager } from '../../plugins/payment/manager';
|
||||
import { UserSubscriptionResolver } from '../../plugins/payment/resolver';
|
||||
import {
|
||||
RcEvent,
|
||||
resolveProductMapping,
|
||||
@@ -40,7 +39,6 @@ type Ctx = {
|
||||
rc: RevenueCatService;
|
||||
webhook: RevenueCatWebhookHandler;
|
||||
controller: RevenueCatWebhookController;
|
||||
subResolver: UserSubscriptionResolver;
|
||||
|
||||
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
||||
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
||||
@@ -87,7 +85,6 @@ test.beforeEach(async t => {
|
||||
const rc = app.get(RevenueCatService);
|
||||
const webhook = app.get(RevenueCatWebhookHandler);
|
||||
const controller = app.get(RevenueCatWebhookController);
|
||||
const subResolver = app.get(UserSubscriptionResolver);
|
||||
|
||||
t.context.module = app;
|
||||
t.context.db = db;
|
||||
@@ -98,7 +95,6 @@ test.beforeEach(async t => {
|
||||
t.context.rc = rc;
|
||||
t.context.webhook = webhook;
|
||||
t.context.controller = controller;
|
||||
t.context.subResolver = subResolver;
|
||||
|
||||
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
|
||||
t.context.mockSubSeq = sequences => {
|
||||
@@ -931,90 +927,3 @@ test('should not dispatch webhook event when authorization header is missing or
|
||||
const after = event.emitAsync.getCalls()?.length || 0;
|
||||
t.is(after - before, 0, 'should not emit event');
|
||||
});
|
||||
|
||||
test('should refresh user subscriptions (empty / revenuecat / stripe-only)', async t => {
|
||||
const { subResolver, db, mockSubSeq } = t.context;
|
||||
|
||||
const currentUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
avatarUrl: '',
|
||||
name: '',
|
||||
disabled: false,
|
||||
hasPassword: true,
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
// prepare mocks:
|
||||
// first call returns Pro subscription
|
||||
// second call returns AI subscription.
|
||||
const stub = mockSubSeq([
|
||||
[
|
||||
{
|
||||
identifier: 'Pro',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-09-01T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-09-01T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.Annual',
|
||||
store: 'app_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
identifier: 'AI',
|
||||
isTrial: false,
|
||||
isActive: true,
|
||||
latestPurchaseDate: new Date('2025-09-02T00:00:00.000Z'),
|
||||
expirationDate: new Date('2026-09-02T00:00:00.000Z'),
|
||||
productId: 'app.affine.pro.ai.Annual',
|
||||
store: 'play_store',
|
||||
willRenew: true,
|
||||
duration: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// case1: empty -> should sync (first sequence)
|
||||
{
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 1, 'Scenario1: RC API called once');
|
||||
t.truthy(
|
||||
subs.find(s => s.plan === 'pro'),
|
||||
'case1: pro saved'
|
||||
);
|
||||
}
|
||||
|
||||
// case2: existing revenuecat -> should sync again (second sequence)
|
||||
{
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 2, 'Scenario2: RC API called second time');
|
||||
t.truthy(
|
||||
subs.find(s => s.plan === 'ai'),
|
||||
'case2: ai saved'
|
||||
);
|
||||
}
|
||||
|
||||
// case3: only stripe subscription -> should NOT sync (call count remains 2)
|
||||
{
|
||||
await db.subscription.deleteMany({
|
||||
where: { targetId: user.id, provider: 'revenuecat' },
|
||||
});
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
provider: 'stripe',
|
||||
status: 'active',
|
||||
recurring: 'monthly',
|
||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||
stripeSubscriptionId: 'sub_123',
|
||||
},
|
||||
});
|
||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
||||
t.is(stub.callCount, 2, 'case3: RC API not called again');
|
||||
t.is(subs.length, 1, 'case3: only stripe subscription returned');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,8 +6,6 @@ import ava from 'ava';
|
||||
import {
|
||||
createTestingApp,
|
||||
getPublicUserById,
|
||||
smallestGif,
|
||||
smallestPng,
|
||||
TestingApp,
|
||||
updateAvatar,
|
||||
} from '../utils';
|
||||
@@ -29,9 +27,7 @@ test('should be able to upload user avatar', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
const avatar = Buffer.from('test');
|
||||
const res = await updateAvatar(app, avatar);
|
||||
|
||||
t.is(res.status, 200);
|
||||
@@ -40,23 +36,19 @@ test('should be able to upload user avatar', async t => {
|
||||
|
||||
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
|
||||
|
||||
t.deepEqual(avatarRes.body, avatar);
|
||||
t.deepEqual(avatarRes.body, Buffer.from('test'));
|
||||
});
|
||||
|
||||
test('should be able to update user avatar, and invalidate old avatar url', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
const avatar = Buffer.from('test');
|
||||
let res = await updateAvatar(app, avatar);
|
||||
|
||||
const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
const newAvatar = await fetch(smallestGif)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
const newAvatar = Buffer.from('new');
|
||||
res = await updateAvatar(app, newAvatar);
|
||||
const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
@@ -66,16 +58,14 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
|
||||
t.is(avatarRes.status, 404);
|
||||
|
||||
const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname);
|
||||
t.deepEqual(newAvatarRes.body, newAvatar);
|
||||
t.deepEqual(newAvatarRes.body, Buffer.from('new'));
|
||||
});
|
||||
|
||||
test('should be able to get public user by id', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.signup();
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
const avatar = Buffer.from('test');
|
||||
await updateAvatar(app, avatar);
|
||||
const u2 = await app.signup();
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ import { type Blob } from '@prisma/client';
|
||||
import { TestingApp } from './testing-app';
|
||||
import { TEST_LOG_LEVEL } from './utils';
|
||||
|
||||
export const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
export const smallestGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
|
||||
|
||||
export async function listBlobs(
|
||||
app: TestingApp,
|
||||
workspaceId: string
|
||||
|
||||
@@ -135,4 +135,4 @@ export const StorageJSONSchema: JSONSchema = {
|
||||
};
|
||||
|
||||
export type * from './provider';
|
||||
export { applyAttachHeaders, autoMetadata, sniffMime, toBuffer } from './utils';
|
||||
export { autoMetadata, toBuffer } from './utils';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { crc32 } from '@node-rs/crc32';
|
||||
import type { Response } from 'express';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { getMime } from '../../../native';
|
||||
@@ -44,53 +43,4 @@ export function autoMetadata(
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const DANGEROUS_INLINE_MIME_PREFIXES = [
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
'image/svg+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/javascript',
|
||||
];
|
||||
|
||||
export function isDangerousInlineMime(mime: string | undefined) {
|
||||
if (!mime) return false;
|
||||
const lower = mime.toLowerCase();
|
||||
return DANGEROUS_INLINE_MIME_PREFIXES.some(p => lower.startsWith(p));
|
||||
}
|
||||
|
||||
export function applyAttachHeaders(
|
||||
res: Response,
|
||||
options: { filename?: string; buffer?: Buffer; contentType?: string }
|
||||
) {
|
||||
let { filename, buffer, contentType } = options;
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (!contentType && buffer) contentType = sniffMime(buffer);
|
||||
if (contentType && isDangerousInlineMime(contentType)) {
|
||||
const safeName = (filename || 'download')
|
||||
.replace(/[\r\n]/g, '')
|
||||
.replace(/[^\w\s.-]/g, '_');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(safeName)}"; filename*=UTF-8''${encodeURIComponent(
|
||||
safeName
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!res.getHeader('Content-Type')) {
|
||||
res.setHeader('Content-Type', contentType || 'application/octet-stream');
|
||||
}
|
||||
}
|
||||
|
||||
export function sniffMime(
|
||||
buffer: Buffer,
|
||||
declared?: string
|
||||
): string | undefined {
|
||||
try {
|
||||
const detected = getMime(buffer);
|
||||
if (detected) return detected;
|
||||
} catch {}
|
||||
return declared;
|
||||
}
|
||||
|
||||
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { BlobQuotaExceeded, StorageQuotaExceeded } from '../error';
|
||||
import { OneKB } from './unit';
|
||||
|
||||
export type CheckExceededResult =
|
||||
| {
|
||||
@@ -53,7 +52,7 @@ export async function readBuffer(
|
||||
|
||||
export async function readBufferWithLimit(
|
||||
readable: Readable,
|
||||
limit: number = 500 * OneKB
|
||||
limit: number
|
||||
): Promise<Buffer> {
|
||||
return readBuffer(readable, size =>
|
||||
size > limit
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Controller, Get, Param, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
applyAttachHeaders,
|
||||
UserAvatarNotFound,
|
||||
} from '../../base';
|
||||
import { ActionForbidden, UserAvatarNotFound } from '../../base';
|
||||
import { Public } from '../auth/guard';
|
||||
import { AvatarStorage } from '../storage';
|
||||
|
||||
@@ -34,10 +30,6 @@ export class UserAvatarController {
|
||||
res.setHeader('last-modified', metadata.lastModified.toISOString());
|
||||
res.setHeader('content-length', metadata.contentLength);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: `${id}`,
|
||||
});
|
||||
|
||||
body.pipe(res);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ import { isNil, omitBy } from 'lodash-es';
|
||||
import {
|
||||
CannotDeleteOwnAccount,
|
||||
type FileUpload,
|
||||
readBufferWithLimit,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../base';
|
||||
@@ -100,20 +98,20 @@ export class UserResolver {
|
||||
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||
avatar: FileUpload
|
||||
) {
|
||||
if (!avatar.mimetype.startsWith('image/')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
const avatarBuffer = await readBufferWithLimit(avatar.createReadStream());
|
||||
const contentType = sniffMime(avatarBuffer, avatar.mimetype);
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
throw new Error(`Invalid file type: ${contentType || 'unknown'}`);
|
||||
}
|
||||
|
||||
const avatarUrl = await this.storage.put(
|
||||
`${user.id}-avatar-${Date.now()}`,
|
||||
avatarBuffer,
|
||||
{ contentType }
|
||||
avatar.createReadStream(),
|
||||
{
|
||||
contentType: avatar.mimetype,
|
||||
}
|
||||
);
|
||||
|
||||
if (user.avatarUrl) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
applyAttachHeaders,
|
||||
BlobNotFound,
|
||||
CallMetric,
|
||||
CommentAttachmentNotFound,
|
||||
@@ -84,10 +83,6 @@ export class WorkspacesController {
|
||||
} else {
|
||||
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: name,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
@@ -220,10 +215,6 @@ export class WorkspacesController {
|
||||
`Comment attachment ${workspaceId}/${docId}/${key} has no metadata`
|
||||
);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: key,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
|
||||
@@ -396,10 +396,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async update(
|
||||
options: UpdateChatSessionOptions,
|
||||
internalCall = false
|
||||
): Promise<string> {
|
||||
async update(options: UpdateChatSessionOptions): Promise<string> {
|
||||
const { userId, sessionId, docId, promptName, pinned, title } = options;
|
||||
const session = await this.getExists(
|
||||
sessionId,
|
||||
@@ -418,16 +415,14 @@ export class CopilotSessionModel extends BaseModel {
|
||||
}
|
||||
|
||||
// not allow to update action session
|
||||
if (!internalCall) {
|
||||
if (session.prompt.action) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update action: ${session.id}`
|
||||
);
|
||||
} else if (docId && session.parentSessionId) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update docId for forked session: ${session.id}`
|
||||
);
|
||||
}
|
||||
if (session.prompt.action) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update action: ${session.id}`
|
||||
);
|
||||
} else if (docId && session.parentSessionId) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update docId for forked session: ${session.id}`
|
||||
);
|
||||
}
|
||||
|
||||
if (promptName) {
|
||||
|
||||
@@ -55,7 +55,7 @@ defineModuleConfig('copilot', {
|
||||
embedding: 'gemini-embedding-001',
|
||||
image: 'gpt-image-1',
|
||||
rerank: 'gpt-4.1',
|
||||
coding: 'claude-sonnet-4-5@20250929',
|
||||
coding: 'claude-sonnet-4@20250514',
|
||||
complex_text_generation: 'gpt-4o-2024-08-06',
|
||||
quick_decision_making: 'gpt-5-mini',
|
||||
quick_text_generation: 'gemini-2.5-flash',
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
EventBus,
|
||||
type FileUpload,
|
||||
RequestMutex,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
@@ -672,11 +671,7 @@ export class CopilotContextResolver {
|
||||
const { filename, mimetype } = content;
|
||||
|
||||
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
|
||||
const file = await session.addFile(
|
||||
blobId,
|
||||
filename,
|
||||
sniffMime(buffer, mimetype) || mimetype
|
||||
);
|
||||
const file = await session.addFile(blobId, filename, mimetype);
|
||||
|
||||
await this.jobs.addFileEmbeddingQueue({
|
||||
userId: user.id,
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from 'rxjs';
|
||||
|
||||
import {
|
||||
applyAttachHeaders,
|
||||
BlobNotFound,
|
||||
CallMetric,
|
||||
Config,
|
||||
@@ -796,10 +795,6 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
} else {
|
||||
this.logger.warn(`Blob ${workspaceId}/${key} has no metadata`);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: key,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
|
||||
@@ -1390,7 +1390,7 @@ If there are items in the content that can be used as to-do tasks, please refer
|
||||
{
|
||||
name: 'Make it real',
|
||||
action: 'Make it real',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -1431,7 +1431,7 @@ When sent new wireframes, respond ONLY with the contents of the html file.`,
|
||||
{
|
||||
name: 'Make it real with text',
|
||||
action: 'Make it real with text',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -1712,7 +1712,7 @@ const modelActions: Prompt[] = [
|
||||
{
|
||||
name: 'Apply Updates',
|
||||
action: 'Apply Updates',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -1868,7 +1868,7 @@ Now apply the \`updates\` to the \`content\`, following the intent in \`op\`, an
|
||||
},
|
||||
{
|
||||
name: 'Code Artifact',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -1932,7 +1932,7 @@ const CHAT_PROMPT: Omit<Prompt, 'name'> = {
|
||||
optionalModels: [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'claude-sonnet-4-5@20250929',
|
||||
'claude-sonnet-4@20250514',
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
@@ -2092,7 +2092,7 @@ Below is the user's query. Please respond in the user's preferred language witho
|
||||
'codeArtifact',
|
||||
'blobRead',
|
||||
],
|
||||
proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'],
|
||||
proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
@@ -41,8 +41,8 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
id: 'claude-3-7-sonnet-20250219',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
@@ -50,6 +50,17 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
defaultForOutputType: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
protected instance!: AnthropicSDKProvider;
|
||||
|
||||
@@ -25,8 +25,8 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4.5',
|
||||
id: 'claude-sonnet-4-5@20250929',
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4@20250514',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
@@ -35,8 +35,8 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4@20250514',
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
id: 'claude-3-7-sonnet@20250219',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
@@ -44,6 +44,17 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-v2@20241022',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
defaultForOutputType: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
protected instance!: GoogleVertexAnthropicProvider;
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
Paginated,
|
||||
PaginationInput,
|
||||
RequestMutex,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
@@ -126,8 +125,8 @@ class DeleteSessionInput {
|
||||
@Field(() => String)
|
||||
workspaceId!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
docId!: string | undefined;
|
||||
@Field(() => String)
|
||||
docId!: string;
|
||||
|
||||
@Field(() => [String])
|
||||
sessionIds!: string[];
|
||||
@@ -738,24 +737,11 @@ export class CopilotResolver {
|
||||
@Args({ name: 'options', type: () => DeleteSessionInput })
|
||||
options: DeleteSessionInput
|
||||
): Promise<string[]> {
|
||||
const { workspaceId, docId, sessionIds } = options;
|
||||
if (docId) {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.doc({ workspaceId, docId })
|
||||
.allowLocal()
|
||||
.assert('Doc.Update');
|
||||
} else {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
}
|
||||
if (!sessionIds.length) {
|
||||
await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
|
||||
if (!options.sessionIds.length) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`;
|
||||
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
|
||||
await using lock = await this.mutex.acquire(lockFlag);
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
@@ -807,10 +793,7 @@ export class CopilotResolver {
|
||||
filename,
|
||||
uploaded.buffer
|
||||
);
|
||||
attachments.push({
|
||||
attachment,
|
||||
mimeType: sniffMime(uploaded.buffer, blob.mimetype) || blob.mimetype,
|
||||
});
|
||||
attachments.push({ attachment, mimeType: blob.mimetype });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -636,10 +636,11 @@ export class ChatSessionService {
|
||||
})
|
||||
.then(s => s.map(s => [s.userId, s.id]));
|
||||
for (const [userId, sessionId] of sessionIds) {
|
||||
await this.models.copilotSession.update(
|
||||
{ userId, sessionId, docId: null },
|
||||
true
|
||||
);
|
||||
await this.models.copilotSession.update({
|
||||
userId,
|
||||
sessionId,
|
||||
docId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
NoCopilotProviderAvailable,
|
||||
OnEvent,
|
||||
OnJob,
|
||||
sniffMime,
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { PromptService } from '../prompt';
|
||||
@@ -86,10 +85,7 @@ export class CopilotTranscriptionService {
|
||||
`${blobId}-${idx}`,
|
||||
buffer
|
||||
);
|
||||
infos.push({
|
||||
url,
|
||||
mimeType: sniffMime(buffer, blob.mimetype) || blob.mimetype,
|
||||
});
|
||||
infos.push({ url, mimeType: blob.mimetype });
|
||||
}
|
||||
|
||||
const model = await this.getModel(userId);
|
||||
|
||||
@@ -2,12 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
FileUpload,
|
||||
JobQueue,
|
||||
PaginationInput,
|
||||
sniffMime,
|
||||
} from '../../../base';
|
||||
import { FileUpload, JobQueue, PaginationInput } from '../../../base';
|
||||
import { ServerFeature, ServerService } from '../../../core';
|
||||
import { Models } from '../../../models';
|
||||
import { CopilotStorage } from '../storage';
|
||||
@@ -69,7 +64,7 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
|
||||
const file = await this.models.copilotWorkspace.addFile(workspaceId, {
|
||||
fileName,
|
||||
blobId,
|
||||
mimeType: sniffMime(buffer, content.mimetype) || content.mimetype,
|
||||
mimeType: content.mimetype,
|
||||
size: buffer.length,
|
||||
});
|
||||
return { blobId, file };
|
||||
|
||||
@@ -221,15 +221,6 @@ export class OAuthController {
|
||||
if (connectedAccount) {
|
||||
// already connected
|
||||
await this.updateConnectedAccount(connectedAccount, tokens);
|
||||
|
||||
if (
|
||||
!connectedAccount.user.emailVerifiedAt &&
|
||||
// external email may change, check if it matches exists email
|
||||
externalAccount.email.toLowerCase() ===
|
||||
connectedAccount.user.email.toLowerCase()
|
||||
) {
|
||||
await this.auth.setEmailVerified(connectedAccount.userId);
|
||||
}
|
||||
return connectedAccount.user;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient, Provider, type User } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
@@ -30,7 +31,6 @@ import { AccessController } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
||||
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||
import { CheckoutParams, SubscriptionService } from './service';
|
||||
import {
|
||||
InvoiceStatus,
|
||||
@@ -463,22 +463,7 @@ export class SubscriptionResolver {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly rcHandler: RevenueCatWebhookHandler
|
||||
) {}
|
||||
|
||||
private normalizeSubscription(s: Subscription) {
|
||||
if (
|
||||
s.variant &&
|
||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
||||
s.variant as SubscriptionVariant
|
||||
)
|
||||
) {
|
||||
s.variant = null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
constructor(private readonly db: PrismaClient) {}
|
||||
|
||||
@ResolveField(() => [SubscriptionType])
|
||||
async subscriptions(
|
||||
@@ -502,9 +487,16 @@ export class UserSubscriptionResolver {
|
||||
},
|
||||
});
|
||||
|
||||
subscriptions.forEach(subscription =>
|
||||
this.normalizeSubscription(subscription)
|
||||
);
|
||||
subscriptions.forEach(subscription => {
|
||||
if (
|
||||
subscription.variant &&
|
||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
||||
subscription.variant as SubscriptionVariant
|
||||
)
|
||||
) {
|
||||
subscription.variant = null;
|
||||
}
|
||||
});
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
@@ -542,71 +534,6 @@ export class UserSubscriptionResolver {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle('strict')
|
||||
@Mutation(() => [SubscriptionType], {
|
||||
description: 'Refresh current user subscriptions and return latest.',
|
||||
})
|
||||
async refreshUserSubscriptions(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<Subscription[]> {
|
||||
if (!user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
let current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const existsPlans = Object.values(SubscriptionPlan);
|
||||
const subscriptions = current.reduce(
|
||||
(r, s) => {
|
||||
if (existsPlans.includes(s.plan as SubscriptionPlan)) {
|
||||
r[s.plan as SubscriptionPlan] = s.provider;
|
||||
}
|
||||
return r;
|
||||
},
|
||||
{} as Record<SubscriptionPlan, Provider>
|
||||
);
|
||||
|
||||
// has revenuecat subscription or no subscription at all
|
||||
const shouldSync =
|
||||
current.length === 0 ||
|
||||
subscriptions.pro === Provider.revenuecat ||
|
||||
subscriptions.ai === Provider.revenuecat;
|
||||
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.rcHandler.syncAppUser(user.id);
|
||||
current = await this.db.subscription.findMany({
|
||||
where: {
|
||||
targetId: user.id,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// ignore errors
|
||||
} catch {}
|
||||
}
|
||||
|
||||
current.forEach(subscription => this.normalizeSubscription(subscription));
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
|
||||
@@ -542,7 +542,7 @@ type DeleteAccount {
|
||||
}
|
||||
|
||||
input DeleteSessionInput {
|
||||
docId: String
|
||||
docId: String!
|
||||
sessionIds: [String!]!
|
||||
workspaceId: String!
|
||||
}
|
||||
@@ -1299,9 +1299,6 @@ type Mutation {
|
||||
"""mark notification as read"""
|
||||
readNotification(id: String!): Boolean!
|
||||
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
||||
|
||||
"""Refresh current user subscriptions and return latest."""
|
||||
refreshUserSubscriptions: [SubscriptionType!]!
|
||||
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
||||
|
||||
"""Remove user avatar"""
|
||||
|
||||
@@ -2218,25 +2218,6 @@ export const setWorkspacePublicByIdMutation = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const refreshSubscriptionMutation = {
|
||||
id: 'refreshSubscriptionMutation' as const,
|
||||
op: 'refreshSubscription',
|
||||
query: `mutation refreshSubscription {
|
||||
refreshUserSubscriptions {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
start
|
||||
end
|
||||
nextBillAt
|
||||
canceledAt
|
||||
variant
|
||||
}
|
||||
}`,
|
||||
deprecations: ["'id' is deprecated: removed"],
|
||||
};
|
||||
|
||||
export const subscriptionQuery = {
|
||||
id: 'subscriptionQuery' as const,
|
||||
op: 'subscription',
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
mutation refreshSubscription {
|
||||
refreshUserSubscriptions {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
start
|
||||
end
|
||||
nextBillAt
|
||||
canceledAt
|
||||
variant
|
||||
}
|
||||
}
|
||||
@@ -654,7 +654,7 @@ export interface DeleteAccount {
|
||||
}
|
||||
|
||||
export interface DeleteSessionInput {
|
||||
docId?: InputMaybe<Scalars['String']['input']>;
|
||||
docId: Scalars['String']['input'];
|
||||
sessionIds: Array<Scalars['String']['input']>;
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
@@ -1451,8 +1451,6 @@ export interface Mutation {
|
||||
/** mark notification as read */
|
||||
readNotification: Scalars['Boolean']['output'];
|
||||
recoverDoc: Scalars['DateTime']['output'];
|
||||
/** Refresh current user subscriptions and return latest. */
|
||||
refreshUserSubscriptions: Array<SubscriptionType>;
|
||||
releaseDeletedBlobs: Scalars['Boolean']['output'];
|
||||
/** Remove user avatar */
|
||||
removeAvatar: RemoveAvatar;
|
||||
@@ -5998,26 +5996,6 @@ export type SetWorkspacePublicByIdMutation = {
|
||||
updateWorkspace: { __typename?: 'WorkspaceType'; id: string };
|
||||
};
|
||||
|
||||
export type RefreshSubscriptionMutationVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type RefreshSubscriptionMutation = {
|
||||
__typename?: 'Mutation';
|
||||
refreshUserSubscriptions: Array<{
|
||||
__typename?: 'SubscriptionType';
|
||||
id: string | null;
|
||||
status: SubscriptionStatus;
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
start: string;
|
||||
end: string | null;
|
||||
nextBillAt: string | null;
|
||||
canceledAt: string | null;
|
||||
variant: SubscriptionVariant | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type SubscriptionQuery = {
|
||||
@@ -7103,11 +7081,6 @@ export type Mutations =
|
||||
variables: SetWorkspacePublicByIdMutationVariables;
|
||||
response: SetWorkspacePublicByIdMutation;
|
||||
}
|
||||
| {
|
||||
name: 'refreshSubscriptionMutation';
|
||||
variables: RefreshSubscriptionMutationVariables;
|
||||
response: RefreshSubscriptionMutation;
|
||||
}
|
||||
| {
|
||||
name: 'updateDocDefaultRoleMutation';
|
||||
variables: UpdateDocDefaultRoleMutationVariables;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(xcodebuild:*)", "Bash(xcbeautify)"],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is the AFFiNE iOS application built with Capacitor, React, and TypeScript. It's a hybrid mobile app that wraps a React web application in a native iOS shell.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Build and Development
|
||||
|
||||
- `yarn dev` - Start development server with live reload
|
||||
- `yarn build` - Build the web application
|
||||
- `yarn sync` - Sync web assets with Capacitor iOS project
|
||||
- `yarn sync:dev` - Sync with development server (CAP_SERVER_URL=http://localhost:8080)
|
||||
- `yarn xcode` - Open Xcode project
|
||||
- `yarn codegen` - Generate GraphQL and Rust bindings
|
||||
- `xcodebuild -workspace App.xcworkspace -scheme App -destination 'platform=iOS Simulator,name=iPhone 15' build | xcbeautify` - Build iOS project with xcbeautify
|
||||
|
||||
### iOS Build Process
|
||||
|
||||
1. `BUILD_TYPE=canary PUBLIC_PATH="/" yarn affine @affine/ios build` - Build web assets
|
||||
2. `yarn affine @affine/ios cap sync` - Sync with iOS project
|
||||
3. `yarn affine @affine/ios cap open ios` - Open in Xcode
|
||||
|
||||
### Live Reload Setup
|
||||
|
||||
1. Run `yarn dev` and select `ios` for Distribution option
|
||||
2. Run `yarn affine @affine/ios sync:dev`
|
||||
3. Run `yarn affine @affine/ios cap open ios`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Technologies
|
||||
|
||||
- **Capacitor 7.x** - Native iOS bridge
|
||||
- **React 19** - UI framework
|
||||
- **TypeScript** - Language
|
||||
- **Blocksuite** - Document editor
|
||||
- **DI Framework** - Dependency injection via `@toeverything/infra`
|
||||
|
||||
### Key Directories
|
||||
|
||||
- `src/` - React application source
|
||||
- `App/` - Native iOS Swift code
|
||||
- `dist/` - Built web assets
|
||||
- `capacitor-cordova-ios-plugins/` - Capacitor plugins
|
||||
|
||||
### Native Bridge Integration
|
||||
|
||||
The app exposes JavaScript APIs to native iOS code through `window` object:
|
||||
|
||||
- `getCurrentServerBaseUrl()` - Get current server URL
|
||||
- `getCurrentI18nLocale()` - Get current locale
|
||||
- `getAiButtonFeatureFlag()` - Check AI button feature flag
|
||||
- `getCurrentWorkspaceId()` - Get current workspace ID
|
||||
- `getCurrentDocId()` - Get current document ID
|
||||
- `getCurrentDocContentInMarkdown()` - Export current doc as markdown
|
||||
- `createNewDocByMarkdownInCurrentWorkspace()` - Import markdown as new doc
|
||||
|
||||
### Swift Code Style
|
||||
|
||||
Follow the guidelines in `AGENTS.md`:
|
||||
|
||||
- 2-space indentation
|
||||
- PascalCase for types, camelCase for properties/methods
|
||||
- Modern Swift features: `@Observable`, `async/await`, `actor`
|
||||
- Protocol-oriented design, dependency injection
|
||||
- Early returns, guard statements for optional unwrapping
|
||||
|
||||
### Build Configuration
|
||||
|
||||
- TypeScript config extends `../../../../tsconfig.web.json`
|
||||
- Webpack bundling via `@affine-tools/cli`
|
||||
- Capacitor config in `capacitor.config.ts`
|
||||
- GraphQL codegen via Apollo
|
||||
- Rust bindings generated via Uniffi
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Workspace packages: `@affine/core`, `@affine/component`, `@affine/env`
|
||||
- Capacitor plugins: App, Browser, Haptics, Keyboard
|
||||
- React ecosystem: React Router, Next Themes
|
||||
- Storage: IDB, Yjs for collaborative editing
|
||||
|
||||
### Testing and Quality
|
||||
|
||||
- TypeScript strict mode enabled
|
||||
- ESLint/Prettier configuration from workspace root
|
||||
- No specific test commands in this package (tests likely in workspace root)
|
||||
|
||||
# Swift Code Style Guidelines
|
||||
|
||||
## Core Style
|
||||
@@ -129,7 +37,7 @@ Follow the guidelines in `AGENTS.md`:
|
||||
|
||||
## Architecture
|
||||
|
||||
- Avoid using protocol-oriented design unless necessary
|
||||
- Protocol-oriented design
|
||||
- Dependency injection over singletons
|
||||
- Composition over inheritance
|
||||
- Factory/Repository patterns
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../App/Products.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -74,4 +74,29 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
super.viewDidDisappear(animated)
|
||||
intelligentsButtonTimer?.invalidate()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||
if motion == .motionShake {
|
||||
showDebugMenu()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import AffinePaywall
|
||||
extension AFFiNEViewController {
|
||||
@objc private func showDebugMenu() {
|
||||
let alert = UIAlertController(title: "Debug Menu", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - Pro", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "Pro")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - AI", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "AI")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Intelligents",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v15),
|
||||
.macCatalyst(.v15),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Intelligents", targets: ["Intelligents"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"),
|
||||
.package(url: "https://github.com/Lakr233/SpringInterpolation", from: "1.3.1"),
|
||||
.package(url: "https://github.com/Lakr233/MSDisplayLink", from: "2.0.8"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Intelligents", dependencies: [
|
||||
"SpringInterpolation",
|
||||
"MSDisplayLink",
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
@@ -6,9 +6,7 @@ import UIKit
|
||||
|
||||
@objc(PayWallPlugin)
|
||||
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
init(
|
||||
associatedController: UIViewController?
|
||||
) {
|
||||
init(associatedController: UIViewController? = nil) {
|
||||
controller = associatedController
|
||||
super.init()
|
||||
}
|
||||
@@ -29,11 +27,7 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// TODO: GET TO KNOW THE PAYWALL TYPE
|
||||
print("[*] showing paywall of type: \(type)")
|
||||
DispatchQueue.main.async {
|
||||
Paywall.presentWall(
|
||||
toController: controller,
|
||||
bindWebContext: self.webView,
|
||||
type: type
|
||||
)
|
||||
Paywall.presentWall(toController: controller, type: type)
|
||||
}
|
||||
|
||||
call.resolve(["success": true, "type": type])
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class RefreshSubscriptionMutation: GraphQLMutation {
|
||||
public static let operationName: String = "refreshSubscription"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"mutation refreshSubscription { refreshUserSubscriptions { __typename id status plan recurring start end nextBillAt canceledAt variant } }"#
|
||||
))
|
||||
|
||||
public init() {}
|
||||
|
||||
public struct Data: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("refreshUserSubscriptions", [RefreshUserSubscription].self),
|
||||
] }
|
||||
|
||||
/// Refresh current user subscriptions and return latest.
|
||||
public var refreshUserSubscriptions: [RefreshUserSubscription] { __data["refreshUserSubscriptions"] }
|
||||
|
||||
/// RefreshUserSubscription
|
||||
///
|
||||
/// Parent Type: `SubscriptionType`
|
||||
public struct RefreshUserSubscription: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.SubscriptionType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("id", String?.self),
|
||||
.field("status", GraphQLEnum<AffineGraphQL.SubscriptionStatus>.self),
|
||||
.field("plan", GraphQLEnum<AffineGraphQL.SubscriptionPlan>.self),
|
||||
.field("recurring", GraphQLEnum<AffineGraphQL.SubscriptionRecurring>.self),
|
||||
.field("start", AffineGraphQL.DateTime.self),
|
||||
.field("end", AffineGraphQL.DateTime?.self),
|
||||
.field("nextBillAt", AffineGraphQL.DateTime?.self),
|
||||
.field("canceledAt", AffineGraphQL.DateTime?.self),
|
||||
.field("variant", GraphQLEnum<AffineGraphQL.SubscriptionVariant>?.self),
|
||||
] }
|
||||
|
||||
@available(*, deprecated, message: "removed")
|
||||
public var id: String? { __data["id"] }
|
||||
public var status: GraphQLEnum<AffineGraphQL.SubscriptionStatus> { __data["status"] }
|
||||
/// The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.
|
||||
/// There won't actually be a subscription with plan 'Free'
|
||||
public var plan: GraphQLEnum<AffineGraphQL.SubscriptionPlan> { __data["plan"] }
|
||||
public var recurring: GraphQLEnum<AffineGraphQL.SubscriptionRecurring> { __data["recurring"] }
|
||||
public var start: AffineGraphQL.DateTime { __data["start"] }
|
||||
public var end: AffineGraphQL.DateTime? { __data["end"] }
|
||||
public var nextBillAt: AffineGraphQL.DateTime? { __data["nextBillAt"] }
|
||||
public var canceledAt: AffineGraphQL.DateTime? { __data["canceledAt"] }
|
||||
public var variant: GraphQLEnum<AffineGraphQL.SubscriptionVariant>? { __data["variant"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
|
||||
public static let operationName: String = "listUserAccessTokens"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query listUserAccessTokens { revealedAccessTokens { __typename id name createdAt expiresAt token } }"#
|
||||
#"query listUserAccessTokens { accessTokens { __typename id name createdAt expiresAt } }"#
|
||||
))
|
||||
|
||||
public init() {}
|
||||
@@ -18,33 +18,31 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("revealedAccessTokens", [RevealedAccessToken].self),
|
||||
.field("accessTokens", [AccessToken].self),
|
||||
] }
|
||||
|
||||
public var revealedAccessTokens: [RevealedAccessToken] { __data["revealedAccessTokens"] }
|
||||
public var accessTokens: [AccessToken] { __data["accessTokens"] }
|
||||
|
||||
/// RevealedAccessToken
|
||||
/// AccessToken
|
||||
///
|
||||
/// Parent Type: `RevealedAccessToken`
|
||||
public struct RevealedAccessToken: AffineGraphQL.SelectionSet {
|
||||
/// Parent Type: `AccessToken`
|
||||
public struct AccessToken: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.RevealedAccessToken }
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AccessToken }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("id", String.self),
|
||||
.field("name", String.self),
|
||||
.field("createdAt", AffineGraphQL.DateTime.self),
|
||||
.field("expiresAt", AffineGraphQL.DateTime?.self),
|
||||
.field("token", String.self),
|
||||
] }
|
||||
|
||||
public var id: String { __data["id"] }
|
||||
public var name: String { __data["name"] }
|
||||
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
|
||||
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
|
||||
public var token: String { __data["token"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public struct DeleteSessionInput: InputObject {
|
||||
}
|
||||
|
||||
public init(
|
||||
docId: GraphQLNullable<String> = nil,
|
||||
docId: String,
|
||||
sessionIds: [String],
|
||||
workspaceId: String
|
||||
) {
|
||||
@@ -22,7 +22,7 @@ public struct DeleteSessionInput: InputObject {
|
||||
])
|
||||
}
|
||||
|
||||
public var docId: GraphQLNullable<String> {
|
||||
public var docId: String {
|
||||
get { __data["docId"] }
|
||||
set { __data["docId"] = newValue }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public extension Objects {
|
||||
static let AccessToken = ApolloAPI.Object(
|
||||
typename: "AccessToken",
|
||||
implementedInterfaces: [],
|
||||
keyFields: nil
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public enum SchemaMetadata: ApolloAPI.SchemaMetadata {
|
||||
|
||||
public static func objectType(forTypename typename: String) -> ApolloAPI.Object? {
|
||||
switch typename {
|
||||
case "AccessToken": return AffineGraphQL.Objects.AccessToken
|
||||
case "AggregateBucketHitsObjectType": return AffineGraphQL.Objects.AggregateBucketHitsObjectType
|
||||
case "AggregateBucketObjectType": return AffineGraphQL.Objects.AggregateBucketObjectType
|
||||
case "AggregateResultObjectType": return AffineGraphQL.Objects.AggregateResultObjectType
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version: 5.9
|
||||
// swift-tools-version: 6.2
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
@@ -21,7 +21,7 @@ let package = Package(
|
||||
targets: [
|
||||
.target(
|
||||
name: "AffinePaywall",
|
||||
dependencies: ["AffineResources"]
|
||||
dependencies: ["AffineResources"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ struct PackageOptionView: View {
|
||||
if !badge.isEmpty {
|
||||
Text(badge)
|
||||
.contentTransition(.numericText())
|
||||
.font(.system(size: 10))
|
||||
.font(.system(size: 12))
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
|
||||
@@ -63,7 +63,6 @@ struct PurchaseFooterView: View {
|
||||
Text("Already Purchased")
|
||||
} else {
|
||||
Text("Restore Purchase")
|
||||
.underline()
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
@@ -71,12 +70,6 @@ struct PurchaseFooterView: View {
|
||||
.foregroundStyle(AffineColors.textSecondary.color)
|
||||
.opacity(viewModel.products.isEmpty ? 0 : 1)
|
||||
.disabled(isPurchased)
|
||||
|
||||
Text("The Monthly and Annual plans renew automatically, but you’re free to cancel at any time if it’s not right for you.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AffineColors.textSecondary.color)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.animation(.spring, value: viewModel.updating)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// PricingConfiguration.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by Claude Code on 9/29/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PricingConfiguration {
|
||||
static let proMonthly = ProductConfiguration(
|
||||
productIdentifier: "app.affine.pro.Monthly",
|
||||
revenueCatIdentifier: "app.affine.pro.Monthly",
|
||||
description: "Monthly",
|
||||
isDefaultSelected: false
|
||||
)
|
||||
|
||||
static let proAnnual = ProductConfiguration(
|
||||
productIdentifier: "app.affine.pro.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.Annual",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true
|
||||
)
|
||||
|
||||
static let aiAnnual = ProductConfiguration(
|
||||
productIdentifier: "app.affine.pro.ai.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.ai.Annual",
|
||||
description: "",
|
||||
isDefaultSelected: true
|
||||
)
|
||||
}
|
||||
|
||||
struct ProductConfiguration {
|
||||
let productIdentifier: String
|
||||
let revenueCatIdentifier: String
|
||||
let description: String
|
||||
let badge: String?
|
||||
let isDefaultSelected: Bool
|
||||
|
||||
init(
|
||||
productIdentifier: String,
|
||||
revenueCatIdentifier: String,
|
||||
description: String,
|
||||
badge: String? = nil,
|
||||
isDefaultSelected: Bool = false
|
||||
) {
|
||||
self.productIdentifier = productIdentifier
|
||||
self.revenueCatIdentifier = revenueCatIdentifier
|
||||
self.description = description
|
||||
self.badge = badge
|
||||
self.isDefaultSelected = isDefaultSelected
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,11 @@ extension SKUnit {
|
||||
secondaryText: "A true multimodal AI copilot.",
|
||||
package: [
|
||||
SKUnitPackageOption(
|
||||
price: "...", // Will be populated from App Store
|
||||
price: "$8.9 per month",
|
||||
description: "",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
secondaryTitle: "",
|
||||
primaryTitle: "$8.9 per month",
|
||||
secondaryTitle: "billed annually",
|
||||
productIdentifier: "app.affine.pro.ai.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.ai.Annual"
|
||||
),
|
||||
|
||||
@@ -16,23 +16,23 @@ extension SKUnit {
|
||||
secondaryText: "For family and small teams.",
|
||||
package: [
|
||||
SKUnitPackageOption(
|
||||
price: "...", // Will be populated from App Store
|
||||
description: PricingConfiguration.proMonthly.description,
|
||||
isDefaultSelected: PricingConfiguration.proMonthly.isDefaultSelected,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
price: "$7.99",
|
||||
description: "Monthly",
|
||||
isDefaultSelected: false,
|
||||
primaryTitle: "Upgrade for $7.99/month",
|
||||
secondaryTitle: "",
|
||||
productIdentifier: PricingConfiguration.proMonthly.productIdentifier,
|
||||
revenueCatIdentifier: PricingConfiguration.proMonthly.revenueCatIdentifier
|
||||
productIdentifier: "app.affine.pro.Monthly",
|
||||
revenueCatIdentifier: "app.affine.pro.Monthly"
|
||||
),
|
||||
SKUnitPackageOption(
|
||||
price: "...", // Will be populated from App Store
|
||||
description: PricingConfiguration.proAnnual.description,
|
||||
badge: PricingConfiguration.proAnnual.badge,
|
||||
isDefaultSelected: PricingConfiguration.proAnnual.isDefaultSelected,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
price: "$6.75",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $6.75/month",
|
||||
secondaryTitle: "",
|
||||
productIdentifier: PricingConfiguration.proAnnual.productIdentifier,
|
||||
revenueCatIdentifier: PricingConfiguration.proAnnual.revenueCatIdentifier
|
||||
productIdentifier: "app.affine.pro.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.Annual"
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable, Sendable {
|
||||
public var id: Int { rawValue }
|
||||
enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
|
||||
var id: Int { rawValue }
|
||||
|
||||
case pro
|
||||
case ai
|
||||
}
|
||||
|
||||
public extension SKUnitCategory {
|
||||
extension SKUnitCategory {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pro: "AFFINE.Pro"
|
||||
|
||||
@@ -23,9 +23,6 @@ final nonisolated class Store: ObservableObject, Sendable {
|
||||
.flatMap(\.package)
|
||||
.map(\.productIdentifier)
|
||||
print("fetching products for identifiers: \(identifiers)")
|
||||
#if DEBUG
|
||||
try await Task.sleep(for: .seconds(1)) // simulate network delay
|
||||
#endif
|
||||
let products = try await Product.products(
|
||||
for: identifiers.map { .init($0) }
|
||||
)
|
||||
|
||||
@@ -42,11 +42,7 @@ extension ViewModel {
|
||||
|
||||
await MainActor.run {
|
||||
self.updating = false
|
||||
}
|
||||
if shouldDismiss {
|
||||
await MainActor.run {
|
||||
self.dismiss()
|
||||
}
|
||||
if shouldDismiss { self.dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,37 +55,11 @@ extension ViewModel {
|
||||
guard !updating else { return }
|
||||
print(#function, unit, option)
|
||||
|
||||
Task.detached {
|
||||
// before we continue, sync any changes from App Store
|
||||
// this will ask user to sign in if needed
|
||||
do {
|
||||
try await store.fetchAppStoreContents()
|
||||
} catch {
|
||||
// ignore user's cancellation on restore, not a huge deal
|
||||
print("updateAppStoreItems error:", error)
|
||||
}
|
||||
|
||||
await MainActor.run { self.updateAppStoreStatus(initial: false) }
|
||||
}
|
||||
updateAppStoreStatus(initial: false)
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
print(#function)
|
||||
|
||||
if let context = associatedWebContext {
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try await context.callAsyncJavaScript(
|
||||
"return await window.updateSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
print("updateSubscriptionState success")
|
||||
} catch {
|
||||
print("updateSubscriptionState error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
associatedController?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
@@ -101,40 +71,28 @@ nonisolated extension ViewModel {
|
||||
await MainActor.run { self.updating = true }
|
||||
|
||||
do {
|
||||
// before we continue, sync any changes from App Store
|
||||
// this will ask user to sign in if needed
|
||||
do {
|
||||
try await store.fetchAppStoreContents()
|
||||
} catch {
|
||||
// ignore user's cancellation on restore, not a huge deal
|
||||
print("updateAppStoreItems error:", error)
|
||||
}
|
||||
|
||||
// now we fetch records from app store
|
||||
let products = try await store.fetchProducts()
|
||||
await MainActor.run {
|
||||
self.products = products
|
||||
self.updatePackageOptions(with: products)
|
||||
}
|
||||
await MainActor.run { self.products = products }
|
||||
|
||||
// fetch purchased items if signed in
|
||||
do {
|
||||
let purchase = try await store.fetchEntitlements()
|
||||
await MainActor.run { self.storePurchasedItems = purchase }
|
||||
await MainActor.run { self.purchasedItems = purchase }
|
||||
} catch {
|
||||
print("fetchEntitlements error:", error)
|
||||
if !initial { throw error }
|
||||
}
|
||||
|
||||
// fetch external items by executing on webview's JS context
|
||||
do {
|
||||
guard let webView = await associatedWebContext else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let purchased = decodeWebContextSubscriptionInformation(result)
|
||||
print("fetched external purchased items:", purchased)
|
||||
await MainActor.run { self.externalPurchasedItems = purchased }
|
||||
} catch {
|
||||
print("fetchExternalEntitlements error:", error.localizedDescription)
|
||||
}
|
||||
|
||||
// select the package under purchased items if any
|
||||
let availablePackages = await availablePackageOptions
|
||||
let purchase = await purchasedItems
|
||||
@@ -166,45 +124,4 @@ nonisolated extension ViewModel {
|
||||
|
||||
await MainActor.run { self.updating = false }
|
||||
}
|
||||
|
||||
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
|
||||
var ans: Set<String> = []
|
||||
|
||||
guard let dict = input as? [String: Any] else {
|
||||
assertionFailure()
|
||||
return ans
|
||||
}
|
||||
|
||||
let pro = dict["pro"] as? [String: Any]
|
||||
let ai = dict["ai"] as? [String: Any]
|
||||
|
||||
if let proPlan = pro?["recurring"] as? String {
|
||||
switch proPlan.lowercased() {
|
||||
case "lifetime":
|
||||
// user actually purchased believer plan
|
||||
// but we map it to yearly plan just for easier handling
|
||||
// do not purchase any of this plan if already purchased
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
case "monthly":
|
||||
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
default:
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
if let aiPlan = ai?["recurring"] as? String {
|
||||
switch aiPlan.lowercased() {
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
|
||||
default:
|
||||
// ai plan can only be purchased as yearly plan
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
return ans
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
class ViewModel: ObservableObject {
|
||||
@@ -24,18 +23,9 @@ class ViewModel: ObservableObject {
|
||||
|
||||
@Published var updating = false
|
||||
@Published var products: [Product] = []
|
||||
@Published var storePurchasedItems: Set<String> = []
|
||||
@Published var externalPurchasedItems: Set<String> = []
|
||||
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
|
||||
|
||||
var purchasedItems: Set<String> {
|
||||
Set<String>()
|
||||
.union(storePurchasedItems)
|
||||
.union(externalPurchasedItems)
|
||||
}
|
||||
@Published var purchasedItems: Set<String> = []
|
||||
|
||||
private(set) weak var associatedController: UIViewController?
|
||||
private(set) weak var associatedWebContext: WKWebView?
|
||||
|
||||
init() {
|
||||
updateAppStoreStatus(initial: true)
|
||||
@@ -51,10 +41,6 @@ class ViewModel: ObservableObject {
|
||||
associatedController = controller
|
||||
}
|
||||
|
||||
func bind(context: WKWebView) {
|
||||
associatedWebContext = context
|
||||
}
|
||||
|
||||
func select(category: SKUnitCategory) {
|
||||
self.category = category
|
||||
let units = SKUnit.units(for: category)
|
||||
@@ -93,84 +79,6 @@ class ViewModel: ObservableObject {
|
||||
|
||||
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||
}
|
||||
|
||||
func updatePackageOptions(with products: [Product]) {
|
||||
var updatedOptions = packageOptions
|
||||
|
||||
for (index, option) in updatedOptions.enumerated() {
|
||||
if let product = products.first(where: { $0.id == option.productIdentifier }) {
|
||||
let price = product.displayPrice
|
||||
let description = product.description
|
||||
|
||||
let (purchasePrimaryTitle, purchaseSecondaryTitle) = purchaseButtonText(
|
||||
for: product,
|
||||
option: option
|
||||
)
|
||||
|
||||
updatedOptions[index] = SKUnitPackageOption(
|
||||
id: option.id,
|
||||
price: price,
|
||||
description: option.description.isEmpty ? description : option.description,
|
||||
badge: option.badge,
|
||||
isDefaultSelected: option.isDefaultSelected,
|
||||
primaryTitle: purchasePrimaryTitle,
|
||||
secondaryTitle: purchaseSecondaryTitle,
|
||||
productIdentifier: option.productIdentifier,
|
||||
revenueCatIdentifier: option.revenueCatIdentifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
packageOptions = updatedOptions
|
||||
}
|
||||
|
||||
private func purchaseButtonText(for product: Product, option: SKUnitPackageOption) -> (String, String) {
|
||||
let monthlyPrice = calculateMonthlyPrice(for: product, option: option)
|
||||
|
||||
if option.productIdentifier.contains(".ai.") {
|
||||
return ("\(monthlyPrice) per month", "billed annually")
|
||||
} else {
|
||||
return ("Upgrade for \(monthlyPrice) per month", "")
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateMonthlyPrice(for product: Product, option _: SKUnitPackageOption) -> String {
|
||||
guard let subscription = product.subscription else {
|
||||
preconditionFailure("Product must have subscription information")
|
||||
}
|
||||
|
||||
switch subscription.subscriptionPeriod.unit {
|
||||
case .year:
|
||||
let yearlyPrice = product.price
|
||||
let monthlyPrice = yearlyPrice / 12.0
|
||||
|
||||
// Round up to ensure total price is slightly lower than yearly price
|
||||
var roundedMonthlyPrice = monthlyPrice
|
||||
var rounded = Decimal()
|
||||
NSDecimalRound(&rounded, &roundedMonthlyPrice, 2, .up)
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = product.priceFormatStyle.currencyCode
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
|
||||
if let formattedMonthlyPrice = formatter.string(from: NSDecimalNumber(decimal: rounded)) {
|
||||
return formattedMonthlyPrice
|
||||
}
|
||||
|
||||
case .month:
|
||||
return product.displayPrice
|
||||
|
||||
case .week, .day:
|
||||
preconditionFailure("Unsupported subscription period: \(subscription.subscriptionPeriod.unit)")
|
||||
|
||||
@unknown default:
|
||||
preconditionFailure("Unknown subscription period")
|
||||
}
|
||||
|
||||
return product.displayPrice
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -191,24 +99,20 @@ extension ViewModel {
|
||||
}
|
||||
|
||||
var selectePackageOption: SKUnitPackageOption {
|
||||
let unitPackageIds = selectedUnit.package.map(\.id)
|
||||
let item = packageOptions
|
||||
.first { $0.id == selectedPackageIdentifier && unitPackageIds.contains($0.id) }
|
||||
let item = selectedUnit.package
|
||||
.first { $0.id == selectedPackageIdentifier }
|
||||
if let item { return item }
|
||||
let defaultItem = packageOptions
|
||||
.first { $0.isDefaultSelected && unitPackageIds.contains($0.id) }
|
||||
let defaultItem = selectedUnit.package.first { $0.isDefaultSelected }
|
||||
if let defaultItem {
|
||||
selectedPackageIdentifier = defaultItem.id
|
||||
return defaultItem
|
||||
}
|
||||
let lastItem = packageOptions
|
||||
.first { unitPackageIds.contains($0.id) }!
|
||||
let lastItem = selectedUnit.package.last!
|
||||
selectedPackageIdentifier = lastItem.id
|
||||
return lastItem
|
||||
}
|
||||
|
||||
var availablePackageOptions: [SKUnitPackageOption] {
|
||||
let unitPackageIds = selectedUnit.package.map(\.id)
|
||||
return packageOptions.filter { unitPackageIds.contains($0.id) }
|
||||
selectedUnit.package
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,14 @@
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public enum Paywall {
|
||||
@MainActor
|
||||
public static func presentWall(
|
||||
toController controller: UIViewController,
|
||||
bindWebContext context: WKWebView?,
|
||||
type: String
|
||||
) {
|
||||
let viewModel = ViewModel()
|
||||
if let context { viewModel.bind(context: context) }
|
||||
switch type.lowercased() {
|
||||
case "pro":
|
||||
viewModel.select(category: .pro)
|
||||
|
||||
@@ -19,7 +19,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.23.0"),
|
||||
.package(url: "https://github.com/apple/swift-collections.git", from: "1.2.1"),
|
||||
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
|
||||
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.2.0"),
|
||||
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
|
||||
.package(url: "https://github.com/Recouse/EventSource.git", from: "0.1.5"),
|
||||
.package(url: "https://github.com/Lakr233/ListViewKit.git", from: "1.1.6"),
|
||||
.package(url: "https://github.com/Lakr233/MarkdownView.git", from: "3.4.2"),
|
||||
|
||||
@@ -275,10 +275,10 @@ private extension ChatManager {
|
||||
|
||||
let closable = ClosableTask(detachedTask: .detached(operation: {
|
||||
let eventSource = EventSource()
|
||||
let dataTask = eventSource.dataTask(for: request)
|
||||
let dataTask = await eventSource.dataTask(for: request)
|
||||
var document = ""
|
||||
self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
|
||||
for await event in dataTask.events() {
|
||||
for await event in await dataTask.events() {
|
||||
switch event {
|
||||
case .open:
|
||||
print("[*] connection opened")
|
||||
|
||||
@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
|
||||
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
|
||||
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
|
||||
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
|
||||
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
|
||||
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
||||
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
||||
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
||||
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
|
||||
CapacitorKeyboard: 09fd91dcde4f8a37313e7f11bde553ad1ed52036
|
||||
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
|
||||
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
|
||||
CapacitorKeyboard: 969647d0ca2e5c737d7300088e2517aa832434e2
|
||||
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
|
||||
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
|
||||
|
||||
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/core": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ServerScope,
|
||||
ServerService,
|
||||
ServersService,
|
||||
SubscriptionService,
|
||||
ValidatorProvider,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
} from '@affine/core/modules/workspace';
|
||||
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
|
||||
import { getWorkerUrl } from '@affine/env/worker';
|
||||
import { refreshSubscriptionMutation } from '@affine/graphql';
|
||||
import { I18n } from '@affine/i18n';
|
||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||
import { Container } from '@blocksuite/affine/global/di';
|
||||
@@ -330,37 +328,6 @@ const frameworkProvider = framework.provider();
|
||||
workspaceRef?.dispose();
|
||||
}
|
||||
};
|
||||
(window as any).getSubscriptionState = async () => {
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
const currentServerId = globalContextService.globalContext.serverId.get();
|
||||
const serversService = frameworkProvider.get(ServersService);
|
||||
const defaultServerService = frameworkProvider.get(DefaultServerService);
|
||||
const currentServer =
|
||||
(currentServerId ? serversService.server$(currentServerId).value : null) ??
|
||||
defaultServerService.server;
|
||||
const subscriptionService = currentServer.scope.get(SubscriptionService);
|
||||
await subscriptionService.subscription.waitForRevalidation();
|
||||
return {
|
||||
pro: subscriptionService.subscription.pro$.value,
|
||||
ai: subscriptionService.subscription.ai$.value,
|
||||
};
|
||||
};
|
||||
(window as any).updateSubscriptionState = async () => {
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
const currentServerId = globalContextService.globalContext.serverId.get();
|
||||
const serversService = frameworkProvider.get(ServersService);
|
||||
const defaultServerService = frameworkProvider.get(DefaultServerService);
|
||||
const currentServer =
|
||||
(currentServerId ? serversService.server$(currentServerId).value : null) ??
|
||||
defaultServerService.server;
|
||||
await currentServer
|
||||
.gql({
|
||||
query: refreshSubscriptionMutation,
|
||||
})
|
||||
.catch(console.error);
|
||||
const subscriptionService = currentServer.scope.get(SubscriptionService);
|
||||
subscriptionService.subscription.revalidate();
|
||||
};
|
||||
|
||||
// setup application lifecycle events, and emit application start event
|
||||
window.addEventListener('focus', () => {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
{ "path": "../../component" },
|
||||
{ "path": "../../core" },
|
||||
{ "path": "../../../common/env" },
|
||||
{ "path": "../../../common/graphql" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../../../blocksuite/affine/all" },
|
||||
|
||||
@@ -16,7 +16,6 @@ export const contentRoot = style({
|
||||
export const iconPicker = style({
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
color: cssVarV2.icon.primary,
|
||||
});
|
||||
globalStyle(`${iconPicker} span:has(svg)`, {
|
||||
lineHeight: 0,
|
||||
|
||||
@@ -148,7 +148,7 @@ export const AffineIconPicker = ({
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<Scrollable.Root className={pickerStyles.iconScrollRoot}>
|
||||
<Scrollable.Root className={pickerStyles.scrollRoot}>
|
||||
<Scrollable.Viewport className={pickerStyles.scrollViewport}>
|
||||
{/* Recent */}
|
||||
{recentIcons.length ? (
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { IconButton } from '../../../button';
|
||||
|
||||
// Memoized individual emoji button to prevent unnecessary re-renders
|
||||
export const EmojiButton = memo(function EmojiButton({
|
||||
emoji,
|
||||
onSelect,
|
||||
}: {
|
||||
emoji: string;
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(emoji);
|
||||
}, [emoji, onSelect]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={emoji}
|
||||
size={24}
|
||||
style={{ padding: 4 }}
|
||||
icon={<span>{emoji}</span>}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,15 +1,145 @@
|
||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||
import { RecentIcon, SearchIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useCallback, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { IconButton } from '../../../button';
|
||||
import Input from '../../../input';
|
||||
import { Loading } from '../../../loading';
|
||||
import { Menu } from '../../../menu';
|
||||
import { Scrollable } from '../../../scrollbar';
|
||||
import * as pickerStyles from '../picker.css';
|
||||
import { GROUP_ICON_MAP, type GroupName, GROUPS } from './constants';
|
||||
import rawData from './data/en.json';
|
||||
// import { emojiGroupList } from './gen-data';
|
||||
import * as styles from './emoji-picker.css';
|
||||
import { EmojiGroups } from './groups';
|
||||
import { useRecentEmojis } from './recent';
|
||||
import type { CompactEmoji } from './type';
|
||||
|
||||
type EmojiGroup = {
|
||||
name: string;
|
||||
emojis: Array<CompactEmoji>;
|
||||
};
|
||||
const emojiGroupList = rawData as EmojiGroup[];
|
||||
|
||||
const useRecentEmojis = () => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<Array<string>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const recentEmojis = localStorage.getItem('recentEmojis');
|
||||
setRecentEmojis(recentEmojis ? recentEmojis.split(',') : []);
|
||||
}, []);
|
||||
|
||||
const add = useCallback((emoji: string) => {
|
||||
setRecentEmojis(prevRecentEmojis => {
|
||||
const newRecentEmojis = [
|
||||
emoji,
|
||||
...prevRecentEmojis.filter(e => e !== emoji),
|
||||
].slice(0, 10);
|
||||
localStorage.setItem('recentEmojis', newRecentEmojis.join(','));
|
||||
return newRecentEmojis;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recentEmojis,
|
||||
add,
|
||||
};
|
||||
};
|
||||
|
||||
// Memoized individual emoji button to prevent unnecessary re-renders
|
||||
const EmojiButton = memo(function EmojiButton({
|
||||
emoji,
|
||||
onSelect,
|
||||
}: {
|
||||
emoji: string;
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(emoji);
|
||||
}, [emoji, onSelect]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={emoji}
|
||||
size={24}
|
||||
style={{ padding: 4 }}
|
||||
icon={<span>{emoji}</span>}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Memoized emoji groups to prevent unnecessary re-renders
|
||||
const EmojiGroups = memo(function EmojiGroups({
|
||||
onSelect,
|
||||
keyword,
|
||||
skin,
|
||||
}: {
|
||||
onSelect: (emoji: string) => void;
|
||||
keyword?: string;
|
||||
skin?: number;
|
||||
}) {
|
||||
const [groups, setGroups] = useState<EmojiGroup[]>([]);
|
||||
|
||||
const loading = !keyword && !groups.length;
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
if (!keyword) {
|
||||
setGroups(emojiGroupList);
|
||||
return;
|
||||
}
|
||||
|
||||
setGroups(
|
||||
emojiGroupList
|
||||
.map(group => ({
|
||||
...group,
|
||||
emojis: group.emojis.filter(emoji =>
|
||||
emoji.tags?.some(tag => tag.includes(keyword.toLowerCase()))
|
||||
),
|
||||
}))
|
||||
.filter(group => group.emojis.length > 0)
|
||||
);
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingWrapper}>
|
||||
<Loading size={16} />
|
||||
<span style={{ marginLeft: 4 }}>Loading emojis...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return groups.map(group => (
|
||||
<div key={group.name} className={pickerStyles.group}>
|
||||
<div className={pickerStyles.groupName} data-group-name={group.name}>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className={pickerStyles.groupGrid}>
|
||||
{group.emojis.map(emoji => (
|
||||
<EmojiButton
|
||||
key={emoji.label}
|
||||
emoji={
|
||||
skin !== undefined && emoji.skins
|
||||
? (emoji.skins[skin]?.unicode ?? emoji.unicode)
|
||||
: emoji.unicode
|
||||
}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
});
|
||||
|
||||
const skinList = [
|
||||
{ unicode: '👋', value: undefined },
|
||||
@@ -25,10 +155,54 @@ export const EmojiPicker = ({
|
||||
}: {
|
||||
onSelect?: (emoji: string) => void;
|
||||
}) => {
|
||||
const [keyword, setKeyword] = useState<string>('');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [keyword, setKeyword] = useState<string>('');
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [skin, setSkin] = useState<number | undefined>(undefined);
|
||||
const { add: addRecent, recentEmojis } = useRecentEmojis();
|
||||
const { recentEmojis, add: addRecent } = useRecentEmojis();
|
||||
|
||||
const checkActiveGroup = useCallback(() => {
|
||||
const scrollable = scrollableRef.current;
|
||||
if (!scrollable) return;
|
||||
|
||||
// get actual scrollable element
|
||||
const viewport = scrollable.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLElement;
|
||||
if (!viewport) return;
|
||||
|
||||
const scrollTop = viewport.scrollTop;
|
||||
|
||||
// find the first group that is at the top of the scrollable element
|
||||
for (let i = emojiGroupList.length - 1; i >= 0; i--) {
|
||||
const group = emojiGroupList[i];
|
||||
const groupElement = viewport.querySelector(
|
||||
`[data-group-name="${group.name}"]`
|
||||
) as HTMLElement;
|
||||
if (!groupElement) continue;
|
||||
|
||||
// use offsetTop to get the position of the element relative to the scrollable element
|
||||
const elementTop = groupElement.offsetTop;
|
||||
|
||||
if (elementTop <= scrollTop + 50) {
|
||||
setActiveGroupId(group.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const jumpToGroup = useCallback((groupName: string) => {
|
||||
const groupElement = scrollableRef.current?.querySelector(
|
||||
`[data-group-name="${groupName}"]`
|
||||
) as HTMLElement;
|
||||
if (!groupElement) return;
|
||||
|
||||
setActiveGroupId(groupName);
|
||||
groupElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
const handleEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
@@ -38,6 +212,10 @@ export const EmojiPicker = ({
|
||||
[addRecent, onSelect]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
checkActiveGroup();
|
||||
}, [checkActiveGroup]);
|
||||
|
||||
return (
|
||||
<div className={pickerStyles.root}>
|
||||
<header className={pickerStyles.searchContainer}>
|
||||
@@ -93,14 +271,62 @@ export const EmojiPicker = ({
|
||||
/>
|
||||
</Menu>
|
||||
</header>
|
||||
<Scrollable.Root className={pickerStyles.scrollRoot} ref={scrollableRef}>
|
||||
<Scrollable.Viewport
|
||||
onScrollEnd={checkActiveGroup}
|
||||
className={pickerStyles.scrollViewport}
|
||||
>
|
||||
{/* Recent */}
|
||||
{recentEmojis.length ? (
|
||||
<div className={pickerStyles.group}>
|
||||
<div className={pickerStyles.groupName} data-group-name="Recent">
|
||||
Recent
|
||||
</div>
|
||||
<div className={pickerStyles.groupGrid}>
|
||||
{recentEmojis.map(emoji => (
|
||||
<EmojiButton
|
||||
key={emoji}
|
||||
emoji={emoji}
|
||||
onSelect={handleEmojiSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Groups */}
|
||||
<EmojiGroups
|
||||
recent={recentEmojis}
|
||||
onSelect={handleEmojiSelect}
|
||||
keyword={keyword}
|
||||
skin={skin}
|
||||
/>
|
||||
{/* Groups */}
|
||||
<EmojiGroups
|
||||
onSelect={handleEmojiSelect}
|
||||
keyword={keyword}
|
||||
skin={skin}
|
||||
/>
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
<div className={styles.footer}>
|
||||
{['Recent', ...GROUPS].map(group => {
|
||||
const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon;
|
||||
const active = activeGroupId === group;
|
||||
return (
|
||||
<IconButton
|
||||
size={18}
|
||||
style={{ padding: 3 }}
|
||||
key={group}
|
||||
icon={
|
||||
<Icon
|
||||
className={
|
||||
active ? styles.footerIconActive : styles.footerIcon
|
||||
}
|
||||
/>
|
||||
}
|
||||
className={clsx(
|
||||
active ? styles.footerButtonActive : styles.footerButton
|
||||
)}
|
||||
onClick={() => jumpToGroup(group)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { RecentIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { IconButton } from '../../../button';
|
||||
import { Loading } from '../../../loading';
|
||||
import {
|
||||
Masonry,
|
||||
type MasonryGroup,
|
||||
type MasonryItem,
|
||||
type MasonryRef,
|
||||
} from '../../../masonry';
|
||||
import * as pickerStyles from '../picker.css';
|
||||
import { GROUP_ICON_MAP, type GroupName, GROUPS } from './constants';
|
||||
import rawData from './data/en.json';
|
||||
import { EmojiButton } from './emoji-button';
|
||||
import * as styles from './emoji-picker.css';
|
||||
import type { CompactEmoji, EmojiGroup } from './type';
|
||||
|
||||
const emojiGroupList = rawData as EmojiGroup[];
|
||||
|
||||
const initEmojiGroupMap = () => {
|
||||
const emojiGroupMap = new Map<string, Map<string, CompactEmoji>>();
|
||||
emojiGroupList.forEach(group => {
|
||||
emojiGroupMap.set(
|
||||
group.name,
|
||||
new Map(group.emojis.map(emoji => [emoji.label, emoji]))
|
||||
);
|
||||
});
|
||||
return emojiGroupMap;
|
||||
};
|
||||
const emojiGroupMap = initEmojiGroupMap();
|
||||
|
||||
const EmojiGroupContext = createContext<{
|
||||
onSelect: (emoji: string) => void;
|
||||
skin?: number;
|
||||
}>({
|
||||
onSelect: () => {},
|
||||
});
|
||||
|
||||
const RecentGroupItem = memo(function RecentGroupItem({
|
||||
itemId,
|
||||
}: {
|
||||
itemId: string;
|
||||
}) {
|
||||
const { onSelect } = useContext(EmojiGroupContext);
|
||||
|
||||
return <EmojiButton emoji={itemId} onSelect={onSelect} />;
|
||||
});
|
||||
const EmojiGroupItem = memo(function EmojiGroupItem({
|
||||
groupId,
|
||||
itemId,
|
||||
}: {
|
||||
groupId: string;
|
||||
itemId: string;
|
||||
}) {
|
||||
const emoji = emojiGroupMap.get(groupId)?.get(itemId);
|
||||
const { onSelect, skin } = useContext(EmojiGroupContext);
|
||||
|
||||
if (!emoji) return null;
|
||||
|
||||
return (
|
||||
<EmojiButton
|
||||
emoji={
|
||||
skin !== undefined && emoji.skins
|
||||
? (emoji.skins[skin]?.unicode ?? emoji.unicode)
|
||||
: emoji.unicode
|
||||
}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const EmojiGroupHeader = memo(function EmojiGroupHeader({
|
||||
groupId,
|
||||
}: {
|
||||
groupId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={pickerStyles.groupName} data-group-name={groupId}>
|
||||
{groupId}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Memoized emoji groups to prevent unnecessary re-renders
|
||||
export const EmojiGroups = memo(function EmojiGroups({
|
||||
recent,
|
||||
onSelect,
|
||||
keyword,
|
||||
skin,
|
||||
}: {
|
||||
onSelect: (emoji: string) => void;
|
||||
recent?: string[];
|
||||
keyword?: string;
|
||||
skin?: number;
|
||||
}) {
|
||||
const masonryRef = useRef<MasonryRef>(null);
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
|
||||
'Recent'
|
||||
);
|
||||
const [groups, setGroups] = useState<EmojiGroup[]>([]);
|
||||
|
||||
const loading = !keyword && !groups.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyword) {
|
||||
setGroups(emojiGroupList);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setGroups(
|
||||
emojiGroupList
|
||||
.map(group => ({
|
||||
...group,
|
||||
emojis: group.emojis.filter(emoji =>
|
||||
emoji.tags?.some(tag => tag.includes(keyword.toLowerCase()))
|
||||
),
|
||||
}))
|
||||
.filter(group => group.emojis.length > 0)
|
||||
);
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const emojiGroups = groups.map(group => {
|
||||
return {
|
||||
id: group.name,
|
||||
height: 30,
|
||||
Component: EmojiGroupHeader,
|
||||
items: group.emojis.map(emoji => {
|
||||
return {
|
||||
id: emoji.label,
|
||||
height: 32,
|
||||
ratio: 1,
|
||||
Component: EmojiGroupItem,
|
||||
} satisfies MasonryItem;
|
||||
}),
|
||||
} satisfies MasonryGroup;
|
||||
});
|
||||
if (recent?.length) {
|
||||
emojiGroups.unshift({
|
||||
id: 'Recent',
|
||||
height: 30,
|
||||
Component: EmojiGroupHeader,
|
||||
items: recent.map(emoji => {
|
||||
return {
|
||||
id: emoji,
|
||||
height: 32,
|
||||
ratio: 1,
|
||||
Component: RecentGroupItem,
|
||||
} satisfies MasonryItem;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return emojiGroups;
|
||||
}, [groups, recent]);
|
||||
const contextValue = useMemo(() => ({ onSelect, skin }), [onSelect, skin]);
|
||||
|
||||
const jumpToGroup = useCallback((groupName: string) => {
|
||||
setActiveGroupId(groupName);
|
||||
masonryRef.current?.scrollToGroup(groupName);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingWrapper}>
|
||||
<Loading size={16} />
|
||||
<span style={{ marginLeft: 4 }}>Loading emojis...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmojiGroupContext.Provider value={contextValue}>
|
||||
<div className={pickerStyles.emojiScrollRoot}>
|
||||
<Masonry
|
||||
ref={masonryRef}
|
||||
virtualScroll
|
||||
items={items}
|
||||
itemWidthMin={32}
|
||||
itemWidth={32}
|
||||
paddingX={12}
|
||||
paddingY={8}
|
||||
gapX={4}
|
||||
gapY={4}
|
||||
onStickyGroupChange={setActiveGroupId}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
{['Recent', ...GROUPS].map(group => {
|
||||
const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon;
|
||||
const active = activeGroupId === group;
|
||||
return (
|
||||
<IconButton
|
||||
size={18}
|
||||
style={{ padding: 3 }}
|
||||
key={group}
|
||||
icon={
|
||||
<Icon
|
||||
className={
|
||||
active ? styles.footerIconActive : styles.footerIcon
|
||||
}
|
||||
/>
|
||||
}
|
||||
className={clsx(
|
||||
active ? styles.footerButtonActive : styles.footerButton
|
||||
)}
|
||||
onClick={() => jumpToGroup(group)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</EmojiGroupContext.Provider>
|
||||
);
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const useRecentEmojis = () => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<Array<string>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const recentEmojis = localStorage.getItem('recentEmojis');
|
||||
setRecentEmojis(recentEmojis ? recentEmojis.split(',') : []);
|
||||
}, []);
|
||||
|
||||
const add = useCallback((emoji: string) => {
|
||||
setRecentEmojis(prevRecentEmojis => {
|
||||
const newRecentEmojis = [
|
||||
emoji,
|
||||
...prevRecentEmojis.filter(e => e !== emoji),
|
||||
].slice(0, 10);
|
||||
localStorage.setItem('recentEmojis', newRecentEmojis.join(','));
|
||||
return newRecentEmojis;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recentEmojis,
|
||||
add,
|
||||
};
|
||||
};
|
||||
@@ -9,8 +9,3 @@ export type CompactEmoji = {
|
||||
unicode: string;
|
||||
skins?: Array<Omit<CompactEmoji, 'skins'>>;
|
||||
};
|
||||
|
||||
export type EmojiGroup = {
|
||||
name: string;
|
||||
emojis: Array<CompactEmoji>;
|
||||
};
|
||||
|
||||
@@ -27,19 +27,8 @@ export const searchInput = style({
|
||||
export const scrollRoot = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
padding: '0px 12px',
|
||||
});
|
||||
export const emojiScrollRoot = style([
|
||||
scrollRoot,
|
||||
{
|
||||
paddingTop: '8px',
|
||||
},
|
||||
]);
|
||||
export const iconScrollRoot = style([
|
||||
scrollRoot,
|
||||
{
|
||||
padding: '0px 12px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const scrollViewport = style({
|
||||
padding: '8px 0px',
|
||||
@@ -63,7 +52,6 @@ export const groupName = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0px 4px',
|
||||
backgroundColor: cssVarV2.layer.background.overlayPanel,
|
||||
});
|
||||
|
||||
export const groupGrid = style({
|
||||
|
||||
@@ -2,12 +2,10 @@ import clsx from 'clsx';
|
||||
import { debounce } from 'lodash-es';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import {
|
||||
forwardRef,
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -63,39 +61,29 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
columns?: number;
|
||||
resizeDebounce?: number;
|
||||
preloadHeight?: number;
|
||||
|
||||
onStickyGroupChange?: (groupId?: string) => void;
|
||||
}
|
||||
|
||||
export type MasonryRef = {
|
||||
scrollToGroup: (groupId: string) => void;
|
||||
};
|
||||
|
||||
export const Masonry = forwardRef<MasonryRef, MasonryProps>(function Masonry(
|
||||
{
|
||||
items,
|
||||
gapX = 12,
|
||||
gapY = 12,
|
||||
itemWidth = 'stretch',
|
||||
itemWidthMin = 100,
|
||||
paddingX = 0,
|
||||
paddingY = 0,
|
||||
className,
|
||||
virtualScroll = false,
|
||||
locateMode = 'leftTop',
|
||||
groupsGap = 0,
|
||||
groupHeaderGapWithItems = 0,
|
||||
stickyGroupHeader = true,
|
||||
collapsedGroups,
|
||||
columns,
|
||||
preloadHeight = 50,
|
||||
resizeDebounce = 20,
|
||||
onGroupCollapse,
|
||||
onStickyGroupChange,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
export const Masonry = ({
|
||||
items,
|
||||
gapX = 12,
|
||||
gapY = 12,
|
||||
itemWidth = 'stretch',
|
||||
itemWidthMin = 100,
|
||||
paddingX = 0,
|
||||
paddingY = 0,
|
||||
className,
|
||||
virtualScroll = false,
|
||||
locateMode = 'leftTop',
|
||||
groupsGap = 0,
|
||||
groupHeaderGapWithItems = 0,
|
||||
stickyGroupHeader = true,
|
||||
collapsedGroups,
|
||||
columns,
|
||||
preloadHeight = 50,
|
||||
resizeDebounce = 20,
|
||||
onGroupCollapse,
|
||||
...props
|
||||
}: MasonryProps) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [layoutMap, setLayoutMap] = useState<
|
||||
@@ -224,9 +212,7 @@ export const Masonry = forwardRef<MasonryRef, MasonryProps>(function Masonry(
|
||||
const scrollY = (e.target as HTMLElement).scrollTop;
|
||||
updateActiveMap(layoutMap, scrollY);
|
||||
if (stickyGroupHeader) {
|
||||
const stickyGroupId = calcSticky({ scrollY, layoutMap });
|
||||
setStickyGroupId(stickyGroupId);
|
||||
onStickyGroupChange?.(stickyGroupId);
|
||||
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
|
||||
}
|
||||
}, 50);
|
||||
rootEl.addEventListener('scroll', handler);
|
||||
@@ -235,29 +221,7 @@ export const Masonry = forwardRef<MasonryRef, MasonryProps>(function Masonry(
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [
|
||||
layoutMap,
|
||||
onStickyGroupChange,
|
||||
stickyGroupHeader,
|
||||
updateActiveMap,
|
||||
virtualScroll,
|
||||
]);
|
||||
|
||||
const scrollToGroup = useCallback(
|
||||
(groupId: string) => {
|
||||
const group = layoutMap.get(groupId);
|
||||
if (!group) return;
|
||||
rootRef.current?.scrollTo({
|
||||
top: group.y,
|
||||
behavior: 'instant',
|
||||
});
|
||||
},
|
||||
[layoutMap]
|
||||
);
|
||||
|
||||
useImperativeHandle<MasonryRef, MasonryRef>(ref, () => {
|
||||
return { scrollToGroup };
|
||||
});
|
||||
}, [layoutMap, stickyGroupHeader, updateActiveMap, virtualScroll]);
|
||||
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
@@ -348,7 +312,7 @@ export const Masonry = forwardRef<MasonryRef, MasonryProps>(function Masonry(
|
||||
<Scrollable.Scrollbar className={styles.scrollbar} />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
type MasonryItemProps = MasonryItem &
|
||||
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> & {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user