mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-09 02:53:45 +00:00
Compare commits
15 Commits
feature/ca
...
v0.25.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3ebd899b | ||
|
|
b59c1f9e57 | ||
|
|
b44fdbce0c | ||
|
|
123d50a484 | ||
|
|
2d1caff45c | ||
|
|
8006812bc0 | ||
|
|
8df7353722 | ||
|
|
12daefdf54 | ||
|
|
9f94d5c216 | ||
|
|
8d6f7047c2 | ||
|
|
a92894990d | ||
|
|
6af1f6ab8d | ||
|
|
e7f76c1737 | ||
|
|
5b52349b96 | ||
|
|
bf87178c26 |
@@ -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@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\"}}",
|
||||
"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\"}}",
|
||||
"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@20250514",
|
||||
"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",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@affine/component": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||
@@ -23,6 +22,7 @@
|
||||
"@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",
|
||||
|
||||
56
blocksuite/affine/blocks/callout/src/callout-block-styles.ts
Normal file
56
blocksuite/affine/blocks/callout/src/callout-block-styles.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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: '10px',
|
||||
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,6 +1,10 @@
|
||||
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, DefaultTheme } from '@blocksuite/affine-model';
|
||||
import { type CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
@@ -8,15 +12,24 @@ 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, signal } from '@preact/signals-core';
|
||||
import { type Signal } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { css, html } from 'lit';
|
||||
import { 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,
|
||||
@@ -35,9 +48,8 @@ export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
|
||||
></uni-lit>`;
|
||||
};
|
||||
const getIcon = (icon?: IconData) => {
|
||||
console.log(icon);
|
||||
if (!icon) {
|
||||
return '💡';
|
||||
return null;
|
||||
}
|
||||
if (icon.type === IconType.Emoji) {
|
||||
return icon.unicode;
|
||||
@@ -47,85 +59,35 @@ const getIcon = (icon?: IconData) => {
|
||||
icons as Record<string, (props: { style: string }) => TemplateResult>
|
||||
)[`${icon.name}Icon`]?.({ style: `color:${icon.color}` });
|
||||
}
|
||||
return '💡';
|
||||
return null;
|
||||
};
|
||||
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
private _popupCloseHandler: (() => void) | null = null;
|
||||
|
||||
.affine-callout-block-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(calloutHostStyles);
|
||||
}
|
||||
|
||||
private _toggleIconPicker() {
|
||||
this.showIconPicker$.value = !this.showIconPicker$.value;
|
||||
private _closeIconPicker() {
|
||||
if (this._popupCloseHandler) {
|
||||
this._popupCloseHandler();
|
||||
this._popupCloseHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _renderIconPicker() {
|
||||
if (!this.showIconPicker$.value) {
|
||||
return html``;
|
||||
private _toggleIconPicker(event: MouseEvent) {
|
||||
// If popup is already open, close it
|
||||
if (this._popupCloseHandler) {
|
||||
this._closeIconPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get IconPickerService from the framework
|
||||
const iconPickerService = this.std.getOptional(IconPickerServiceIdentifier);
|
||||
if (!iconPickerService) {
|
||||
console.warn('IconPickerService not found');
|
||||
return html``;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the uni-component from the service
|
||||
@@ -135,23 +97,31 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
const props = {
|
||||
onSelect: (iconData?: IconData) => {
|
||||
this.model.props.icon$.value = iconData;
|
||||
this._closeEmojiMenu(); // Close the picker after selection
|
||||
this._closeIconPicker(); // Close the picker after selection
|
||||
},
|
||||
onClose: () => {
|
||||
this._closeEmojiMenu();
|
||||
this._closeIconPicker();
|
||||
},
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
class="icon-picker-container"
|
||||
>
|
||||
${renderUniLit(iconPickerComponent, props)}
|
||||
</div>
|
||||
`;
|
||||
// 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private readonly _handleBlockClick = (event: MouseEvent) => {
|
||||
@@ -164,6 +134,13 @@ 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;
|
||||
@@ -206,33 +183,35 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
|
||||
override renderBlock() {
|
||||
const icon = this.model.props.icon$.value;
|
||||
const background = this.model.props.background$.value;
|
||||
const backgroundColorName = this.model.props.backgroundColorName$.value;
|
||||
const backgroundColor = (
|
||||
cssVarV2.block.callout.background as Record<string, string>
|
||||
)[backgroundColorName ?? ''];
|
||||
|
||||
const themeProvider = this.std.get(ThemeProvider);
|
||||
const theme = themeProvider.theme$.value;
|
||||
const backgroundColor = themeProvider.generateColorProperty(
|
||||
background || DefaultTheme.NoteBackgroundColorMap.White,
|
||||
DefaultTheme.NoteBackgroundColorMap.White,
|
||||
theme
|
||||
);
|
||||
const iconContent = getIcon(icon);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="affine-callout-block-container"
|
||||
class="${calloutBlockContainerStyles}"
|
||||
@click=${this._handleBlockClick}
|
||||
style=${styleMap({
|
||||
backgroundColor: backgroundColor,
|
||||
backgroundColor: backgroundColor ?? 'transparent',
|
||||
})}
|
||||
>
|
||||
<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">
|
||||
${iconContent
|
||||
? html`
|
||||
<div
|
||||
@click=${this._toggleIconPicker}
|
||||
contenteditable="false"
|
||||
class="${calloutEmojiContainerStyles}"
|
||||
>
|
||||
<span class="${calloutEmojiStyles}" data-testid="callout-emoji"
|
||||
>${iconContent}</span
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
<div class="${calloutChildrenStyles}">
|
||||
${this.renderChildren(this.model)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import { CalloutBlockModel, DefaultTheme } from '@blocksuite/affine-model';
|
||||
import {
|
||||
createPopup,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import { CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type IconData,
|
||||
IconPickerServiceIdentifier,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { PaletteIcon } from '@blocksuite/icons/lit';
|
||||
import { DeleteIcon, PaletteIcon, SmileIcon } 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',
|
||||
@@ -38,27 +48,7 @@ const backgroundColorAction = {
|
||||
if (!model) return null;
|
||||
|
||||
const updateBackground = (color: string) => {
|
||||
// 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 });
|
||||
ctx.store.updateBlock(model, { backgroundColorName: color });
|
||||
};
|
||||
|
||||
return html`
|
||||
@@ -103,12 +93,102 @@ 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,11 +1,14 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
52
blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts
Normal file
52
blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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,4 +1,5 @@
|
||||
import { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import { updateBlockAlign } from '@blocksuite/affine-block-note';
|
||||
import { ImageBlockModel, TextAlign } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
@@ -12,6 +13,9 @@ import {
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
@@ -51,7 +55,55 @@ const builtinToolbarConfig = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.comment',
|
||||
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',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,6 +143,15 @@ 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'),
|
||||
@@ -162,6 +171,7 @@ 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,6 +150,10 @@ 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({
|
||||
@@ -161,7 +165,7 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
|
||||
</div>`;
|
||||
|
||||
return html`
|
||||
<div class=${'affine-list-block-container'}>
|
||||
<div class=${'affine-list-block-container'} style="${textAlignStyle}">
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-list-rich-text-wrapper': true,
|
||||
|
||||
@@ -8,3 +8,4 @@ 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';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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,9 +4,15 @@ 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,
|
||||
@@ -17,7 +23,7 @@ import {
|
||||
import { HeadingsIcon } from '@blocksuite/icons/lit';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
|
||||
import { updateBlockType } from '../commands';
|
||||
import { updateBlockAlign, updateBlockType } from '../commands';
|
||||
import { tooltips } from './tooltips';
|
||||
|
||||
let basicIndex = 0;
|
||||
@@ -60,6 +66,10 @@ 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) =>
|
||||
@@ -89,6 +99,26 @@ 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,7 +5,10 @@ import {
|
||||
NoteBlockSchema,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { textConversionConfigs } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
textAlignConfigs,
|
||||
textConversionConfigs,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
focusBlockEnd,
|
||||
focusBlockStart,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
indentBlocks,
|
||||
selectBlock,
|
||||
selectBlocksBetween,
|
||||
updateBlockAlign,
|
||||
updateBlockType,
|
||||
} from './commands';
|
||||
import { moveBlockConfigs } from './move-block';
|
||||
@@ -157,6 +161,36 @@ 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) => {
|
||||
@@ -568,6 +602,7 @@ class NoteKeymap {
|
||||
...this._bindMoveBlockHotKey(),
|
||||
...this._bindQuickActionHotKey(),
|
||||
...this._bindTextConversionHotKey(),
|
||||
...this._bindTextAlignHotKey(),
|
||||
Tab: ctx => {
|
||||
const [success] = this.std.command.exec(indentBlocks);
|
||||
|
||||
|
||||
@@ -264,6 +264,10 @@ 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({
|
||||
@@ -288,6 +292,7 @@ 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,7 +8,10 @@ import {
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { updateBlockType } from '@blocksuite/affine-block-note';
|
||||
import {
|
||||
updateBlockAlign,
|
||||
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';
|
||||
@@ -23,8 +26,12 @@ import {
|
||||
import {
|
||||
EmbedLinkedDocBlockSchema,
|
||||
EmbedSyncedDocBlockSchema,
|
||||
type TextAlign,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { textConversionConfigs } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
textAlignConfigs,
|
||||
textConversionConfigs,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
copySelectedModelsCommand,
|
||||
deleteSelectedModelsCommand,
|
||||
@@ -46,6 +53,7 @@ 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,
|
||||
@@ -130,6 +138,64 @@ 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],
|
||||
@@ -291,6 +357,7 @@ const turnIntoLinkedDoc = {
|
||||
export const builtinToolbarConfig = {
|
||||
actions: [
|
||||
conversionsActionGroup,
|
||||
alignActionGroup,
|
||||
inlineTextActionGroup,
|
||||
highlightActionGroup,
|
||||
turnIntoDatabase,
|
||||
|
||||
@@ -144,6 +144,16 @@ 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',
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -6,22 +6,20 @@ 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;
|
||||
background: Color;
|
||||
backgroundColorName?: string;
|
||||
} & BlockMeta;
|
||||
|
||||
export const CalloutBlockSchema = defineBlockSchema({
|
||||
flavour: 'affine:callout',
|
||||
props: (internal): CalloutProps => ({
|
||||
icon: undefined,
|
||||
icon: { type: 'emoji', unicode: '💡' } as IconData,
|
||||
text: internal.Text(),
|
||||
background: DefaultTheme.NoteBackgroundColorMap.White,
|
||||
backgroundColorName: 'grey',
|
||||
'meta:createdAt': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types.js';
|
||||
import { ImageBlockTransformer } from './image-transformer.js';
|
||||
|
||||
@@ -20,6 +21,7 @@ export type ImageBlockProps = {
|
||||
rotate: number;
|
||||
size?: number;
|
||||
comments?: Record<string, boolean>;
|
||||
textAlign?: TextAlign;
|
||||
} & Omit<GfxCommonBlockProps, 'scale'> &
|
||||
BlockMeta;
|
||||
|
||||
@@ -34,6 +36,7 @@ const defaultImageProps: ImageBlockProps = {
|
||||
rotate: 0,
|
||||
size: -1,
|
||||
comments: undefined,
|
||||
textAlign: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -13,6 +14,7 @@ export type ListType = 'bulleted' | 'numbered' | 'todo' | 'toggle';
|
||||
export type ListProps = {
|
||||
type: ListType;
|
||||
text: Text;
|
||||
textAlign?: TextAlign;
|
||||
checked: boolean;
|
||||
collapsed: boolean;
|
||||
order: number | null;
|
||||
@@ -25,6 +27,7 @@ export const ListBlockSchema = defineBlockSchema({
|
||||
({
|
||||
type: 'bulleted',
|
||||
text: internal.Text(),
|
||||
textAlign: undefined,
|
||||
checked: false,
|
||||
collapsed: false,
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type Text,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
export type ParagraphType =
|
||||
@@ -19,6 +20,7 @@ export type ParagraphType =
|
||||
|
||||
export type ParagraphProps = {
|
||||
type: ParagraphType;
|
||||
textAlign?: TextAlign;
|
||||
text: Text;
|
||||
collapsed: boolean;
|
||||
comments?: Record<string, boolean>;
|
||||
@@ -29,6 +31,7 @@ export const ParagraphBlockSchema = defineBlockSchema({
|
||||
props: (internal): ParagraphProps => ({
|
||||
type: 'text',
|
||||
text: internal.Text(),
|
||||
textAlign: undefined,
|
||||
collapsed: false,
|
||||
comments: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import type { TextAlign } from '../../consts';
|
||||
import type { BlockMeta } from '../../utils/types';
|
||||
|
||||
export type TableCell = {
|
||||
@@ -30,6 +31,7 @@ export interface TableBlockProps extends BlockMeta {
|
||||
// key = `${rowId}:${columnId}`
|
||||
cells: Record<string, TableCell>;
|
||||
comments?: Record<string, boolean>;
|
||||
textAlign?: TextAlign;
|
||||
}
|
||||
|
||||
export interface TableCellSerialized {
|
||||
@@ -53,6 +55,7 @@ export const TableBlockSchema = defineBlockSchema({
|
||||
columns: {},
|
||||
cells: {},
|
||||
comments: undefined,
|
||||
textAlign: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
'meta:updatedAt': undefined,
|
||||
|
||||
35
blocksuite/affine/rich-text/src/align.ts
Normal file
35
blocksuite/affine/rich-text/src/align.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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,3 +1,4 @@
|
||||
export { type TextAlignConfig, textAlignConfigs } from './align';
|
||||
export { type TextConversionConfig, textConversionConfigs } from './conversion';
|
||||
export {
|
||||
asyncGetRichText,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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',
|
||||
@@ -22,15 +21,8 @@ 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 =
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.6.8",
|
||||
"oxlint": "^1.15.0",
|
||||
"oxlint": "~1.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semver": "^7.6.3",
|
||||
"serve": "^14.2.4",
|
||||
|
||||
@@ -473,7 +473,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
> should honor requested pro model during active
|
||||
|
||||
'claude-sonnet-4@20250514'
|
||||
'claude-sonnet-4-5@20250929'
|
||||
|
||||
> should fallback to default model when requesting non-optional model during active
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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@20250514'] },
|
||||
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'] },
|
||||
optionalModels: [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'claude-sonnet-4@20250514',
|
||||
'claude-sonnet-4-5@20250929',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -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@20250514');
|
||||
const model7 = await s.resolveModel(true, 'claude-sonnet-4-5@20250929');
|
||||
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, User } from '@prisma/client';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import { omit } from 'lodash-es';
|
||||
import Sinon from 'sinon';
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -39,6 +40,7 @@ type Ctx = {
|
||||
rc: RevenueCatService;
|
||||
webhook: RevenueCatWebhookHandler;
|
||||
controller: RevenueCatWebhookController;
|
||||
subResolver: UserSubscriptionResolver;
|
||||
|
||||
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
||||
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
||||
@@ -85,6 +87,7 @@ 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;
|
||||
@@ -95,6 +98,7 @@ 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 => {
|
||||
@@ -927,3 +931,90 @@ 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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ defineModuleConfig('copilot', {
|
||||
embedding: 'gemini-embedding-001',
|
||||
image: 'gpt-image-1',
|
||||
rerank: 'gpt-4.1',
|
||||
coding: 'claude-sonnet-4@20250514',
|
||||
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',
|
||||
|
||||
@@ -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@20250514',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
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@20250514',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -1712,7 +1712,7 @@ const modelActions: Prompt[] = [
|
||||
{
|
||||
name: 'Apply Updates',
|
||||
action: 'Apply Updates',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
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@20250514',
|
||||
model: 'claude-sonnet-4-5@20250929',
|
||||
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@20250514',
|
||||
'claude-sonnet-4-5@20250929',
|
||||
],
|
||||
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@20250514'],
|
||||
proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
@@ -40,27 +50,6 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
id: 'claude-3-7-sonnet-20250219',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -24,6 +24,16 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4.5',
|
||||
id: 'claude-sonnet-4-5@20250929',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
id: 'claude-sonnet-4@20250514',
|
||||
@@ -34,27 +44,6 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Claude 3.7 Sonnet',
|
||||
id: 'claude-3-7-sonnet@20250219',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -125,8 +125,8 @@ class DeleteSessionInput {
|
||||
@Field(() => String)
|
||||
workspaceId!: string;
|
||||
|
||||
@Field(() => String)
|
||||
docId!: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
docId!: string | undefined;
|
||||
|
||||
@Field(() => [String])
|
||||
sessionIds!: string[];
|
||||
@@ -737,11 +737,24 @@ export class CopilotResolver {
|
||||
@Args({ name: 'options', type: () => DeleteSessionInput })
|
||||
options: DeleteSessionInput
|
||||
): Promise<string[]> {
|
||||
await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
|
||||
if (!options.sessionIds.length) {
|
||||
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) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
|
||||
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`;
|
||||
await using lock = await this.mutex.acquire(lockFlag);
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Provider, type User } from '@prisma/client';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
@@ -31,6 +30,7 @@ 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,7 +463,22 @@ export class SubscriptionResolver {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(private readonly db: PrismaClient) {}
|
||||
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;
|
||||
}
|
||||
|
||||
@ResolveField(() => [SubscriptionType])
|
||||
async subscriptions(
|
||||
@@ -487,16 +502,9 @@ export class UserSubscriptionResolver {
|
||||
},
|
||||
});
|
||||
|
||||
subscriptions.forEach(subscription => {
|
||||
if (
|
||||
subscription.variant &&
|
||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
||||
subscription.variant as SubscriptionVariant
|
||||
)
|
||||
) {
|
||||
subscription.variant = null;
|
||||
}
|
||||
});
|
||||
subscriptions.forEach(subscription =>
|
||||
this.normalizeSubscription(subscription)
|
||||
);
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
@@ -534,6 +542,71 @@ 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,6 +1299,9 @@ 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,6 +2218,25 @@ 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',
|
||||
|
||||
13
packages/common/graphql/src/graphql/subscription-refresh.gql
Normal file
13
packages/common/graphql/src/graphql/subscription-refresh.gql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation refreshSubscription {
|
||||
refreshUserSubscriptions {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
start
|
||||
end
|
||||
nextBillAt
|
||||
canceledAt
|
||||
variant
|
||||
}
|
||||
}
|
||||
@@ -654,7 +654,7 @@ export interface DeleteAccount {
|
||||
}
|
||||
|
||||
export interface DeleteSessionInput {
|
||||
docId: Scalars['String']['input'];
|
||||
docId?: InputMaybe<Scalars['String']['input']>;
|
||||
sessionIds: Array<Scalars['String']['input']>;
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
@@ -1451,6 +1451,8 @@ 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;
|
||||
@@ -5996,6 +5998,26 @@ 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 = {
|
||||
@@ -7081,6 +7103,11 @@ export type Mutations =
|
||||
variables: SetWorkspacePublicByIdMutationVariables;
|
||||
response: SetWorkspacePublicByIdMutation;
|
||||
}
|
||||
| {
|
||||
name: 'refreshSubscriptionMutation';
|
||||
variables: RefreshSubscriptionMutationVariables;
|
||||
response: RefreshSubscriptionMutation;
|
||||
}
|
||||
| {
|
||||
name: 'updateDocDefaultRoleMutation';
|
||||
variables: UpdateDocDefaultRoleMutationVariables;
|
||||
|
||||
7
packages/frontend/apps/ios/.claude/settings.local.json
Normal file
7
packages/frontend/apps/ios/.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(xcodebuild:*)", "Bash(xcbeautify)"],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,95 @@
|
||||
# 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
|
||||
@@ -37,7 +129,7 @@
|
||||
|
||||
## Architecture
|
||||
|
||||
- Protocol-oriented design
|
||||
- Avoid using protocol-oriented design unless necessary
|
||||
- Dependency injection over singletons
|
||||
- Composition over inheritance
|
||||
- Factory/Repository patterns
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
// @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 { accessTokens { __typename id name createdAt expiresAt } }"#
|
||||
#"query listUserAccessTokens { revealedAccessTokens { __typename id name createdAt expiresAt token } }"#
|
||||
))
|
||||
|
||||
public init() {}
|
||||
@@ -18,31 +18,33 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("accessTokens", [AccessToken].self),
|
||||
.field("revealedAccessTokens", [RevealedAccessToken].self),
|
||||
] }
|
||||
|
||||
public var accessTokens: [AccessToken] { __data["accessTokens"] }
|
||||
public var revealedAccessTokens: [RevealedAccessToken] { __data["revealedAccessTokens"] }
|
||||
|
||||
/// AccessToken
|
||||
/// RevealedAccessToken
|
||||
///
|
||||
/// Parent Type: `AccessToken`
|
||||
public struct AccessToken: AffineGraphQL.SelectionSet {
|
||||
/// Parent Type: `RevealedAccessToken`
|
||||
public struct RevealedAccessToken: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AccessToken }
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.RevealedAccessToken }
|
||||
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: String,
|
||||
docId: GraphQLNullable<String> = nil,
|
||||
sessionIds: [String],
|
||||
workspaceId: String
|
||||
) {
|
||||
@@ -22,7 +22,7 @@ public struct DeleteSessionInput: InputObject {
|
||||
])
|
||||
}
|
||||
|
||||
public var docId: String {
|
||||
public var docId: GraphQLNullable<String> {
|
||||
get { __data["docId"] }
|
||||
set { __data["docId"] = newValue }
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// @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,7 +20,6 @@ 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: 6.2
|
||||
// swift-tools-version: 5.9
|
||||
// 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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ struct PurchaseFooterView: View {
|
||||
Text("Already Purchased")
|
||||
} else {
|
||||
Text("Restore Purchase")
|
||||
.underline()
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
@@ -70,6 +71,12 @@ 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// 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: "$8.9 per month",
|
||||
price: "...", // Will be populated from App Store
|
||||
description: "",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "$8.9 per month",
|
||||
secondaryTitle: "billed annually",
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
secondaryTitle: "",
|
||||
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: "$7.99",
|
||||
description: "Monthly",
|
||||
isDefaultSelected: false,
|
||||
primaryTitle: "Upgrade for $7.99/month",
|
||||
price: "...", // Will be populated from App Store
|
||||
description: PricingConfiguration.proMonthly.description,
|
||||
isDefaultSelected: PricingConfiguration.proMonthly.isDefaultSelected,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
secondaryTitle: "",
|
||||
productIdentifier: "app.affine.pro.Monthly",
|
||||
revenueCatIdentifier: "app.affine.pro.Monthly"
|
||||
productIdentifier: PricingConfiguration.proMonthly.productIdentifier,
|
||||
revenueCatIdentifier: PricingConfiguration.proMonthly.revenueCatIdentifier
|
||||
),
|
||||
SKUnitPackageOption(
|
||||
price: "$6.75",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $6.75/month",
|
||||
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
|
||||
secondaryTitle: "",
|
||||
productIdentifier: "app.affine.pro.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.Annual"
|
||||
productIdentifier: PricingConfiguration.proAnnual.productIdentifier,
|
||||
revenueCatIdentifier: PricingConfiguration.proAnnual.revenueCatIdentifier
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -23,6 +23,9 @@ 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,7 +42,11 @@ extension ViewModel {
|
||||
|
||||
await MainActor.run {
|
||||
self.updating = false
|
||||
if shouldDismiss { self.dismiss() }
|
||||
}
|
||||
if shouldDismiss {
|
||||
await MainActor.run {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +59,18 @@ extension ViewModel {
|
||||
guard !updating else { return }
|
||||
print(#function, unit, option)
|
||||
|
||||
updateAppStoreStatus(initial: false)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
@@ -71,18 +86,12 @@ 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 }
|
||||
await MainActor.run {
|
||||
self.products = products
|
||||
self.updatePackageOptions(with: products)
|
||||
}
|
||||
|
||||
// fetch purchased items if signed in
|
||||
do {
|
||||
|
||||
@@ -24,6 +24,7 @@ class ViewModel: ObservableObject {
|
||||
@Published var updating = false
|
||||
@Published var products: [Product] = []
|
||||
@Published var purchasedItems: Set<String> = []
|
||||
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
|
||||
|
||||
private(set) weak var associatedController: UIViewController?
|
||||
|
||||
@@ -79,6 +80,84 @@ 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
|
||||
@@ -99,20 +178,24 @@ extension ViewModel {
|
||||
}
|
||||
|
||||
var selectePackageOption: SKUnitPackageOption {
|
||||
let item = selectedUnit.package
|
||||
.first { $0.id == selectedPackageIdentifier }
|
||||
let unitPackageIds = selectedUnit.package.map(\.id)
|
||||
let item = packageOptions
|
||||
.first { $0.id == selectedPackageIdentifier && unitPackageIds.contains($0.id) }
|
||||
if let item { return item }
|
||||
let defaultItem = selectedUnit.package.first { $0.isDefaultSelected }
|
||||
let defaultItem = packageOptions
|
||||
.first { $0.isDefaultSelected && unitPackageIds.contains($0.id) }
|
||||
if let defaultItem {
|
||||
selectedPackageIdentifier = defaultItem.id
|
||||
return defaultItem
|
||||
}
|
||||
let lastItem = selectedUnit.package.last!
|
||||
let lastItem = packageOptions
|
||||
.first { unitPackageIds.contains($0.id) }!
|
||||
selectedPackageIdentifier = lastItem.id
|
||||
return lastItem
|
||||
}
|
||||
|
||||
var availablePackageOptions: [SKUnitPackageOption] {
|
||||
selectedUnit.package
|
||||
let unitPackageIds = selectedUnit.package.map(\.id)
|
||||
return packageOptions.filter { unitPackageIds.contains($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,10 +275,10 @@ private extension ChatManager {
|
||||
|
||||
let closable = ClosableTask(detachedTask: .detached(operation: {
|
||||
let eventSource = EventSource()
|
||||
let dataTask = await eventSource.dataTask(for: request)
|
||||
let dataTask = eventSource.dataTask(for: request)
|
||||
var document = ""
|
||||
self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
|
||||
for await event in await dataTask.events() {
|
||||
for await event in 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: 106e7a4205f4618d582b886a975657c61179138d
|
||||
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
||||
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
||||
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
|
||||
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
|
||||
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
|
||||
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
||||
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
|
||||
CapacitorKeyboard: 969647d0ca2e5c737d7300088e2517aa832434e2
|
||||
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
|
||||
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
|
||||
CapacitorKeyboard: 09fd91dcde4f8a37313e7f11bde553ad1ed52036
|
||||
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
|
||||
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
|
||||
|
||||
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/core": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ServerScope,
|
||||
ServerService,
|
||||
ServersService,
|
||||
SubscriptionService,
|
||||
ValidatorProvider,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
@@ -38,6 +39,7 @@ 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';
|
||||
@@ -328,6 +330,37 @@ 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,6 +10,7 @@
|
||||
{ "path": "../../component" },
|
||||
{ "path": "../../core" },
|
||||
{ "path": "../../../common/env" },
|
||||
{ "path": "../../../common/graphql" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../../../blocksuite/affine/all" },
|
||||
|
||||
@@ -16,6 +16,7 @@ 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.scrollRoot}>
|
||||
<Scrollable.Root className={pickerStyles.iconScrollRoot}>
|
||||
<Scrollable.Viewport className={pickerStyles.scrollViewport}>
|
||||
{/* Recent */}
|
||||
{recentIcons.length ? (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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,145 +1,15 @@
|
||||
import { RecentIcon, SearchIcon } from '@blocksuite/icons/rc';
|
||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, 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 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>
|
||||
));
|
||||
});
|
||||
import { EmojiGroups } from './groups';
|
||||
import { useRecentEmojis } from './recent';
|
||||
|
||||
const skinList = [
|
||||
{ unicode: '👋', value: undefined },
|
||||
@@ -155,54 +25,10 @@ export const EmojiPicker = ({
|
||||
}: {
|
||||
onSelect?: (emoji: string) => void;
|
||||
}) => {
|
||||
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 { 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 { add: addRecent, recentEmojis } = useRecentEmojis();
|
||||
|
||||
const handleEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
@@ -212,10 +38,6 @@ export const EmojiPicker = ({
|
||||
[addRecent, onSelect]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
checkActiveGroup();
|
||||
}, [checkActiveGroup]);
|
||||
|
||||
return (
|
||||
<div className={pickerStyles.root}>
|
||||
<header className={pickerStyles.searchContainer}>
|
||||
@@ -271,62 +93,14 @@ 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
|
||||
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>
|
||||
{/* Groups */}
|
||||
<EmojiGroups
|
||||
recent={recentEmojis}
|
||||
onSelect={handleEmojiSelect}
|
||||
keyword={keyword}
|
||||
skin={skin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
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,3 +9,8 @@ export type CompactEmoji = {
|
||||
unicode: string;
|
||||
skins?: Array<Omit<CompactEmoji, 'skins'>>;
|
||||
};
|
||||
|
||||
export type EmojiGroup = {
|
||||
name: string;
|
||||
emojis: Array<CompactEmoji>;
|
||||
};
|
||||
|
||||
@@ -27,8 +27,19 @@ 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',
|
||||
@@ -52,6 +63,7 @@ export const groupName = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0px 4px',
|
||||
backgroundColor: cssVarV2.layer.background.overlayPanel,
|
||||
});
|
||||
|
||||
export const groupGrid = style({
|
||||
|
||||
@@ -2,10 +2,12 @@ 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,
|
||||
@@ -61,29 +63,39 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
columns?: number;
|
||||
resizeDebounce?: number;
|
||||
preloadHeight?: number;
|
||||
|
||||
onStickyGroupChange?: (groupId?: string) => void;
|
||||
}
|
||||
|
||||
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) => {
|
||||
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
|
||||
) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [layoutMap, setLayoutMap] = useState<
|
||||
@@ -212,7 +224,9 @@ export const Masonry = ({
|
||||
const scrollY = (e.target as HTMLElement).scrollTop;
|
||||
updateActiveMap(layoutMap, scrollY);
|
||||
if (stickyGroupHeader) {
|
||||
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
|
||||
const stickyGroupId = calcSticky({ scrollY, layoutMap });
|
||||
setStickyGroupId(stickyGroupId);
|
||||
onStickyGroupChange?.(stickyGroupId);
|
||||
}
|
||||
}, 50);
|
||||
rootEl.addEventListener('scroll', handler);
|
||||
@@ -221,7 +235,29 @@ export const Masonry = ({
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [layoutMap, stickyGroupHeader, updateActiveMap, virtualScroll]);
|
||||
}, [
|
||||
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 };
|
||||
});
|
||||
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
@@ -312,7 +348,7 @@ export const Masonry = ({
|
||||
<Scrollable.Scrollbar className={styles.scrollbar} />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
type MasonryItemProps = MasonryItem &
|
||||
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> & {
|
||||
|
||||
@@ -112,12 +112,18 @@ export const calcLayout = (
|
||||
const ratioMode = 'ratio' in item;
|
||||
const height = ratioMode ? item.ratio * width : item.height;
|
||||
|
||||
const aroundGapXValue =
|
||||
columns > 1
|
||||
? (totalWidth - paddingX * 2 - width * columns) / (columns - 1)
|
||||
: 0;
|
||||
const gapXValue = Math.max(gapX, aroundGapXValue);
|
||||
|
||||
if (ratioMode) {
|
||||
const minRatio = Math.min(...ratioStack);
|
||||
const minRatioIndex = ratioStack.indexOf(minRatio);
|
||||
const minHeight = heightStack[minRatioIndex];
|
||||
const hasGap = heightStack[minRatioIndex] ? gapY : 0;
|
||||
const x = minRatioIndex * (width + gapX) + paddingX;
|
||||
const x = minRatioIndex * (width + gapXValue) + paddingX;
|
||||
const y = finalHeight + minHeight + hasGap;
|
||||
|
||||
ratioStack[minRatioIndex] += item.ratio * 10000;
|
||||
@@ -133,7 +139,7 @@ export const calcLayout = (
|
||||
const minHeight = Math.min(...heightStack);
|
||||
const minHeightIndex = heightStack.indexOf(minHeight);
|
||||
const hasGap = heightStack[minHeightIndex] ? gapY : 0;
|
||||
const x = minHeightIndex * (width + gapX) + paddingX;
|
||||
const x = minHeightIndex * (width + gapXValue) + paddingX;
|
||||
const y = finalHeight + minHeight + hasGap;
|
||||
|
||||
const ratio = height / width;
|
||||
@@ -193,7 +199,7 @@ export const calcSticky = (options: {
|
||||
|
||||
const stickyGroupEntry = groupEntries.find(([_, xywh], index) => {
|
||||
const next = groupEntries[index + 1];
|
||||
return xywh.y < scrollY && (!next || next[1].y > scrollY);
|
||||
return xywh.y <= scrollY && (!next || next[1].y > scrollY);
|
||||
});
|
||||
|
||||
return stickyGroupEntry
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"lit": "^3.2.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lottie-react": "^2.4.0",
|
||||
"mermaid": "^10.9.1",
|
||||
"mermaid": "^11.1.0",
|
||||
"mp4-muxer": "^5.2.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"next-themes": "^0.4.4",
|
||||
|
||||
@@ -441,7 +441,7 @@ declare global {
|
||||
) => Promise<AIHistory[] | undefined>;
|
||||
cleanup: (
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
docId: string | undefined,
|
||||
sessionIds: string[]
|
||||
) => Promise<void>;
|
||||
ids: (
|
||||
|
||||
@@ -130,6 +130,9 @@ export class AIChatPanelTitle extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor openDoc!: (docId: string, sessionId: string) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor deleteSession!: (session: BlockSuitePresets.AIRecentSession) => void;
|
||||
|
||||
private readonly openPlayground = () => {
|
||||
const playgroundContent = html`
|
||||
<playground-content
|
||||
@@ -182,6 +185,7 @@ export class AIChatPanelTitle extends SignalWatcher(
|
||||
.onTogglePin=${this.togglePin}
|
||||
.onOpenSession=${this.openSession}
|
||||
.onOpenDoc=${this.openDoc}
|
||||
.onSessionDelete=${this.deleteSession}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.notificationService=${this.notificationService}
|
||||
></ai-chat-toolbar>
|
||||
|
||||
@@ -237,6 +237,31 @@ export class ChatPanel extends SignalWatcher(
|
||||
return this.session;
|
||||
};
|
||||
|
||||
private readonly deleteSession = async (
|
||||
session: BlockSuitePresets.AIRecentSession
|
||||
) => {
|
||||
if (!AIProvider.histories) {
|
||||
return;
|
||||
}
|
||||
const confirm = await this.notificationService.confirm({
|
||||
title: 'Delete this history?',
|
||||
message:
|
||||
'Do you want to delete this AI conversation history? Once deleted, it cannot be recovered.',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
if (confirm) {
|
||||
await AIProvider.histories.cleanup(
|
||||
session.workspaceId,
|
||||
session.docId || undefined,
|
||||
[session.sessionId]
|
||||
);
|
||||
if (session.sessionId === this.session?.sessionId) {
|
||||
this.newSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly updateSession = async (options: UpdateChatSessionInput) => {
|
||||
await AIProvider.session?.updateSession(options);
|
||||
const session = await AIProvider.session?.getSession(
|
||||
@@ -413,6 +438,7 @@ export class ChatPanel extends SignalWatcher(
|
||||
.togglePin=${this.togglePin}
|
||||
.openSession=${this.openSession}
|
||||
.openDoc=${this.openDoc}
|
||||
.deleteSession=${this.deleteSession}
|
||||
></ai-chat-panel-title>
|
||||
${keyed(
|
||||
this.hasPinned ? this.session?.sessionId : this.doc.id,
|
||||
|
||||
@@ -42,6 +42,11 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor onOpenDoc!: (docId: string, sessionId: string) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSessionDelete!: (
|
||||
session: BlockSuitePresets.AIRecentSession
|
||||
) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docDisplayConfig!: DocDisplayConfig;
|
||||
|
||||
@@ -198,7 +203,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
||||
.workspaceId=${this.workspaceId}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.onSessionClick=${this.onSessionClick}
|
||||
.onSessionDelete=${this.onSessionDelete}
|
||||
.onDocClick=${this.onDocClick}
|
||||
.notificationService=${this.notificationService}
|
||||
></ai-session-history>
|
||||
`,
|
||||
portalStyles: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { DeleteIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
@@ -62,7 +63,6 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 24px;
|
||||
padding: 2px 4px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
@@ -85,6 +85,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
padding: 2px 4px;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -94,7 +95,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
||||
.ai-session-doc {
|
||||
display: flex;
|
||||
width: 120px;
|
||||
padding: 0px 4px;
|
||||
padding: 2px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
@@ -117,6 +118,36 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-session-item-delete {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
border-radius: 2px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
}
|
||||
|
||||
.ai-session-item:hover .ai-session-item-delete {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
${scrollbarStyle('.ai-session-history')}
|
||||
@@ -134,6 +165,11 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor onSessionClick!: (sessionId: string) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSessionDelete!: (
|
||||
session: BlockSuitePresets.AIRecentSession
|
||||
) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onDocClick!: (docId: string, sessionId: string) => void;
|
||||
|
||||
@@ -272,6 +308,16 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
||||
${session.docId
|
||||
? this.renderSessionDoc(session.docId, session.sessionId)
|
||||
: nothing}
|
||||
<div
|
||||
class="ai-session-item-delete"
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
this.onSessionDelete(session);
|
||||
}}
|
||||
>
|
||||
${DeleteIcon()}
|
||||
<affine-tooltip>Delete</affine-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -160,6 +160,12 @@ export abstract class ArtifactTool<
|
||||
`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// open the preview panel immediately
|
||||
this.openOrUpdatePreviewPanel();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const err = this.getErrorTemplate();
|
||||
if (err) {
|
||||
|
||||
@@ -261,7 +261,7 @@ export class CopilotClient {
|
||||
|
||||
async cleanupSessions(input: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
docId: string | undefined;
|
||||
sessionIds: string[];
|
||||
}) {
|
||||
try {
|
||||
|
||||
@@ -794,7 +794,7 @@ Could you make a new website based on these notes and send back just the html fi
|
||||
},
|
||||
cleanup: async (
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
docId: string | undefined,
|
||||
sessionIds: string[]
|
||||
) => {
|
||||
await client.cleanupSessions({ workspaceId, docId, sessionIds });
|
||||
|
||||
@@ -10,6 +10,9 @@ export const docIconPickerTrigger = style({
|
||||
fontSize: 60,
|
||||
lineHeight: 1,
|
||||
},
|
||||
'&[data-icon-type="emoji"]': {
|
||||
fontFamily: 'emoji',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ export function useAsyncCallback<T extends any[]>(
|
||||
const handleAsyncError = React.useContext(AsyncCallbackContext);
|
||||
return React.useCallback(
|
||||
(...args: any) => {
|
||||
// oxlint-disable-next-line exhaustive-deps
|
||||
callback(...args).catch(e => handleAsyncError(e));
|
||||
},
|
||||
[callback, handleAsyncError, ...deps] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[...deps] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ type KeyboardShortcutsI18NKeys =
|
||||
| 'bodyText'
|
||||
| 'increaseIndent'
|
||||
| 'reduceIndent'
|
||||
| 'alignLeft'
|
||||
| 'alignCenter'
|
||||
| 'alignRight'
|
||||
| 'groupDatabase'
|
||||
| 'moveUp'
|
||||
| 'moveDown'
|
||||
@@ -185,6 +188,9 @@ export const useMacPageKeyboardShortcuts = (): ShortcutMap => {
|
||||
[tH('6')]: ['⌘', '⌥', '6'],
|
||||
[t('increaseIndent')]: ['Tab'],
|
||||
[t('reduceIndent')]: ['⇧', 'Tab'],
|
||||
[t('alignLeft')]: ['⌘', '⇧', 'L'],
|
||||
[t('alignCenter')]: ['⌘', '⇧', 'E'],
|
||||
[t('alignRight')]: ['⌘', '⇧', 'R'],
|
||||
[t('groupDatabase')]: ['⌘', 'G'],
|
||||
[t('switch')]: ['⌥', 'S'],
|
||||
// not implement yet
|
||||
@@ -242,6 +248,9 @@ export const useWinPageKeyboardShortcuts = (): ShortcutMap => {
|
||||
[tH('6')]: ['Ctrl', 'Shift', '6'],
|
||||
[t('increaseIndent')]: ['Tab'],
|
||||
[t('reduceIndent')]: ['Shift+Tab'],
|
||||
[t('alignLeft')]: ['Ctrl', 'Shift', 'L'],
|
||||
[t('alignCenter')]: ['Ctrl', 'Shift', 'E'],
|
||||
[t('alignRight')]: ['Ctrl', 'Shift', 'R'],
|
||||
[t('groupDatabase')]: ['Ctrl + G'],
|
||||
['Switch']: ['Alt + S'],
|
||||
// not implement yet
|
||||
|
||||
@@ -1,69 +1,16 @@
|
||||
import {
|
||||
type IconData as ComponentIconData,
|
||||
IconPicker,
|
||||
IconType,
|
||||
uniReactRoot,
|
||||
} from '@affine/component';
|
||||
import { IconPicker, uniReactRoot } from '@affine/component';
|
||||
// Import the identifier for internal use
|
||||
import {
|
||||
type IconData,
|
||||
type IconPickerOptions,
|
||||
type IconPickerService as IIconPickerService,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { type IconPickerService as IIconPickerService } from '@blocksuite/affine-shared/services';
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
|
||||
// Re-export types from BlockSuite shared services
|
||||
export type {
|
||||
IconData,
|
||||
IconPickerOptions,
|
||||
IconPickerService as IIconPickerService,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
export { IconPickerServiceIdentifier } from '@blocksuite/affine-shared/services';
|
||||
|
||||
// Convert between BlockSuite IconData and Component IconData
|
||||
function convertToBlockSuiteIconData(
|
||||
componentIconData: ComponentIconData
|
||||
): IconData {
|
||||
if (componentIconData.type === IconType.Emoji) {
|
||||
return {
|
||||
type: 'emoji',
|
||||
value: componentIconData.unicode,
|
||||
};
|
||||
} else if (componentIconData.type === IconType.AffineIcon) {
|
||||
return {
|
||||
type: 'icon',
|
||||
value: componentIconData.name,
|
||||
};
|
||||
}
|
||||
// For other types, default to icon type
|
||||
return {
|
||||
type: 'icon',
|
||||
value: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
export class IconPickerService extends Service implements IIconPickerService {
|
||||
public readonly iconPickerComponent =
|
||||
uniReactRoot.createUniComponent(IconPicker);
|
||||
|
||||
renderIconPicker(options: IconPickerOptions): TemplateResult {
|
||||
const element = document.createElement('div');
|
||||
|
||||
// Adapt the options to match IconPicker component's expected interface
|
||||
const adaptedOptions = {
|
||||
onSelect: options.onSelect
|
||||
? (data?: ComponentIconData) => {
|
||||
if (data && options.onSelect) {
|
||||
const blockSuiteIconData = convertToBlockSuiteIconData(data);
|
||||
options.onSelect(blockSuiteIconData);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
onClose: options.onClose,
|
||||
};
|
||||
|
||||
this.iconPickerComponent(element, adaptedOptions, () => {});
|
||||
return html`${element}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2509,6 +2509,18 @@ export function useAFFiNEI18N(): {
|
||||
* `Just now`
|
||||
*/
|
||||
["com.affine.just-now"](): string;
|
||||
/**
|
||||
* `Align center`
|
||||
*/
|
||||
["com.affine.keyboardShortcuts.alignCenter"](): string;
|
||||
/**
|
||||
* `Align left`
|
||||
*/
|
||||
["com.affine.keyboardShortcuts.alignLeft"](): string;
|
||||
/**
|
||||
* `Align right`
|
||||
*/
|
||||
["com.affine.keyboardShortcuts.alignRight"](): string;
|
||||
/**
|
||||
* `Append to daily note`
|
||||
*/
|
||||
|
||||
@@ -626,6 +626,9 @@
|
||||
"com.affine.journal.placeholder.title": "No Journal",
|
||||
"com.affine.journal.placeholder.create": "Create Daily Journal",
|
||||
"com.affine.just-now": "Just now",
|
||||
"com.affine.keyboardShortcuts.alignCenter": "Align center",
|
||||
"com.affine.keyboardShortcuts.alignLeft": "Align left",
|
||||
"com.affine.keyboardShortcuts.alignRight": "Align right",
|
||||
"com.affine.keyboardShortcuts.appendDailyNote": "Append to daily note",
|
||||
"com.affine.keyboardShortcuts.bodyText": "Body text",
|
||||
"com.affine.keyboardShortcuts.bold": "Bold",
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('AIChatWith/Attachments', () => {
|
||||
test.beforeEach(async ({ loggedInPage: page, utils }) => {
|
||||
await utils.testUtils.setupTestEnvironment(
|
||||
page,
|
||||
'claude-sonnet-4@20250514'
|
||||
'claude-sonnet-4-5@20250929'
|
||||
);
|
||||
await utils.chatPanel.openChatPanel(page);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('AIChatWith/Collections', () => {
|
||||
test.beforeEach(async ({ loggedInPage: page, utils }) => {
|
||||
await utils.testUtils.setupTestEnvironment(
|
||||
page,
|
||||
'claude-sonnet-4@20250514'
|
||||
'claude-sonnet-4-5@20250929'
|
||||
);
|
||||
await utils.chatPanel.openChatPanel(page);
|
||||
await utils.editor.clearAllCollections(page);
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('AISettings/Embedding', () => {
|
||||
test.beforeEach(async ({ loggedInPage: page, utils }) => {
|
||||
await utils.testUtils.setupTestEnvironment(
|
||||
page,
|
||||
'claude-sonnet-4@20250514'
|
||||
'claude-sonnet-4-5@20250929'
|
||||
);
|
||||
await utils.chatPanel.openChatPanel(page);
|
||||
});
|
||||
|
||||
@@ -24,9 +24,9 @@ test('add callout block using slash menu and change emoji', async ({
|
||||
}) => {
|
||||
await type(page, '/callout\naaaa\nbbbb');
|
||||
const callout = page.locator('affine-callout');
|
||||
const emoji = page.locator('affine-callout .affine-callout-emoji');
|
||||
const emoji = page.locator('affine-callout').getByTestId('callout-emoji');
|
||||
await expect(callout).toBeVisible();
|
||||
await expect(emoji).toContainText('😀');
|
||||
await expect(emoji).toContainText('💡');
|
||||
|
||||
const paragraph = page.locator('affine-callout affine-paragraph');
|
||||
await expect(paragraph).toHaveCount(2);
|
||||
@@ -35,18 +35,6 @@ test('add callout block using slash menu and change emoji', async ({
|
||||
await expect(vLine).toHaveCount(2);
|
||||
expect(await vLine.nth(0).innerText()).toBe('aaaa');
|
||||
expect(await vLine.nth(1).innerText()).toBe('bbbb');
|
||||
|
||||
await emoji.click();
|
||||
const emojiMenu = page.locator('affine-emoji-menu');
|
||||
await expect(emojiMenu).toBeVisible();
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^😀😃😄😁😆😅🤣😂🙂$/ })
|
||||
.getByLabel('😆')
|
||||
.click();
|
||||
await page.getByTestId('page-editor-blank').click();
|
||||
await expect(emojiMenu).not.toBeVisible();
|
||||
await expect(emoji).toContainText('😆');
|
||||
});
|
||||
|
||||
test('press backspace after callout block', async ({ page }) => {
|
||||
|
||||
@@ -386,7 +386,7 @@ test.describe('paste to code block', () => {
|
||||
await pressEnter(page);
|
||||
await addCodeBlock(page);
|
||||
const plainTextCode = [
|
||||
' model: anthropic("claude-3-7-sonnet-20250219"),',
|
||||
' model: anthropic("claude-sonnet-4-5-20250929"),',
|
||||
' prompt: How many people will live in the world in 2040?',
|
||||
' providerOptions: {',
|
||||
' anthropic: {',
|
||||
|
||||
@@ -92,13 +92,6 @@ test('should format quick bar show when clicking drag handle', async ({
|
||||
|
||||
const { formatBar } = getFormatBar(page);
|
||||
await expect(formatBar).toBeVisible();
|
||||
|
||||
const box = await formatBar.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("formatBar doesn't exist");
|
||||
}
|
||||
assertAlmostEqual(box.x, 251, 5);
|
||||
assertAlmostEqual(box.y - dragHandleRect.y, -55.5, 5);
|
||||
});
|
||||
|
||||
test('should format quick bar show when select text by keyboard', async ({
|
||||
@@ -548,17 +541,6 @@ test('should format quick bar work in single block selection', async ({
|
||||
const { formatBar } = getFormatBar(page);
|
||||
await expect(formatBar).toBeVisible();
|
||||
|
||||
const formatRect = await formatBar.boundingBox();
|
||||
const selectionRect = await blockSelections.boundingBox();
|
||||
if (!formatRect) {
|
||||
throw new Error('formatRect is not found');
|
||||
}
|
||||
if (!selectionRect) {
|
||||
throw new Error('selectionRect is not found');
|
||||
}
|
||||
assertAlmostEqual(formatRect.x - selectionRect.x, 147.5, 10);
|
||||
assertAlmostEqual(formatRect.y - selectionRect.y, -48, 10);
|
||||
|
||||
const boldBtn = formatBar.getByTestId('bold');
|
||||
await boldBtn.click();
|
||||
const italicBtn = formatBar.getByTestId('italic');
|
||||
@@ -603,17 +585,6 @@ test('should format quick bar work in multiple block selection', async ({
|
||||
const formatBarController = getFormatBar(page);
|
||||
await expect(formatBarController.formatBar).toBeVisible();
|
||||
|
||||
const box = await formatBarController.formatBar.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("formatBar doesn't exist");
|
||||
}
|
||||
const rect = await blockSelections.first().boundingBox();
|
||||
if (!rect) {
|
||||
throw new Error('rect is not found');
|
||||
}
|
||||
assertAlmostEqual(box.x - rect.x, 147.5, 10);
|
||||
assertAlmostEqual(box.y - rect.y, -48, 10);
|
||||
|
||||
await formatBarController.boldBtn.click();
|
||||
await formatBarController.italicBtn.click();
|
||||
await formatBarController.underlineBtn.click();
|
||||
|
||||
@@ -606,7 +606,7 @@ test.describe('slash search', () => {
|
||||
await expect(slashMenu).toBeVisible();
|
||||
|
||||
await type(page, 'c');
|
||||
await expect(slashItems).toHaveCount(8);
|
||||
await expect(slashItems).toHaveCount(9);
|
||||
await expect(slashItems.nth(0).locator('.text')).toHaveText(['Copy']);
|
||||
await expect(slashItems.nth(1).locator('.text')).toHaveText(['Italic']);
|
||||
await expect(slashItems.nth(2).locator('.text')).toHaveText(['New Doc']);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"@googleapis/androidpublisher": "^28.0.0",
|
||||
"@googleapis/androidpublisher": "^31.0.0",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1287,6 +1287,7 @@ export const PackageList = [
|
||||
'packages/frontend/component',
|
||||
'packages/frontend/core',
|
||||
'packages/common/env',
|
||||
'packages/common/graphql',
|
||||
'packages/frontend/i18n',
|
||||
'packages/common/nbstore',
|
||||
'blocksuite/affine/all',
|
||||
|
||||
Reference in New Issue
Block a user