Compare commits

..

1 Commits

Author SHA1 Message Date
L-Sun 3d30670987 refactor(editor): use add and delete impl move block 2025-05-29 17:36:29 +08:00
191 changed files with 1887 additions and 4333 deletions
+2 -3
View File
@@ -151,8 +151,7 @@ jobs:
- name: Clippy
run: |
rustup component add clippy
cargo clippy --workspace --exclude affine_server_native --all-targets --all-features -- -D warnings
cargo clippy -p affine_server_native --all-targets --all-features -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
check-git-status:
name: Check Git Status
@@ -924,7 +923,7 @@ jobs:
uses: taiki-e/install-action@nextest
- name: Run tests
run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast
run: cargo nextest run --release --no-fail-fast
copilot-api-test:
name: Server Copilot Api Test
+23 -2
View File
@@ -117,10 +117,31 @@ jobs:
name: android
path: packages/frontend/apps/android/dist
ios:
runs-on: ${{ github.ref_name == 'canary' && 'macos-latest' || 'blaze/macos-14' }}
determine-ios-runner:
runs-on: ubuntu-latest
needs:
- build-ios-web
outputs:
RUNNER: ${{ steps.runner.outputs.RUNNER }}
steps:
- name: Determine Runner
id: runner
# Randomly pick runner with 80% chance for blaze/macos-14 and 20% chance for namespace-profile-macos
# blaze/macos-14 is free but has limited concurrency
run: |
RANDOM_NUMBER=$(( $RANDOM % 100 + 1 ))
if [ $RANDOM_NUMBER -le 20 ]; then
echo "Selected namespace-profile-macos (20% probability)"
echo "RUNNER=namespace-profile-macos" >> $GITHUB_OUTPUT
else
echo "Selected blaze/macos-14 (80% probability)"
echo "RUNNER=blaze/macos-14" >> $GITHUB_OUTPUT
fi
ios:
runs-on: ${{ github.ref_name == 'canary' && 'macos-latest' || needs.determine-ios-runner.outputs.RUNNER }}
needs:
- determine-ios-runner
steps:
- uses: actions/checkout@v4
- name: Download mobile artifact
Generated
+390 -221
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -47,9 +47,9 @@ log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
mimalloc = "0.1"
nanoid = "0.4"
napi = { version = "3.0.0-beta.3", features = ["async", "chrono_date", "error_anyhow", "napi9", "serde"] }
napi = { version = "3.0.0-alpha.31", features = ["async", "chrono_date", "error_anyhow", "napi9", "serde"] }
napi-build = { version = "2" }
napi-derive = { version = "3.0.0-beta.3" }
napi-derive = { version = "3.0.0-alpha.28" }
nom = "8"
notify = { version = "8", features = ["serde"] }
objc2 = "0.6"
@@ -77,12 +77,12 @@ smol_str = "0.3"
sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "tls-rustls"] }
strum_macros = "0.27.0"
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
text-splitter = "0.27"
text-splitter = "0.25"
thiserror = "2"
tiktoken-rs = "0.7"
tokio = "1.45"
tiktoken-rs = "0.6"
tokio = "1.37"
tree-sitter = { version = "0.25" }
tree-sitter-c = { version = "0.24" }
tree-sitter-c = { version = "0.23" }
tree-sitter-c-sharp = { version = "0.23" }
tree-sitter-cpp = { version = "0.23" }
tree-sitter-go = { version = "0.23" }
@@ -407,7 +407,7 @@ const builtinSurfaceToolbarConfig = {
if (options?.viewType !== 'embed') return;
const { flavour, styles } = options;
let style: EmbedCardStyle = model.props.style;
let { style } = model.props;
if (!styles.includes(style)) {
style = styles[0];
@@ -482,26 +482,24 @@ const builtinSurfaceToolbarConfig = {
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'b.style',
actions: (
[
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
{
id: 'vertical',
label: 'Large vertical style',
},
{
id: 'cube',
label: 'Small vertical style',
},
] as const
).filter(action => BookmarkStyles.includes(action.id)),
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
{
id: 'vertical',
label: 'Large vertical style',
},
{
id: 'cube',
label: 'Small vertical style',
},
].filter(action => BookmarkStyles.includes(action.id as EmbedCardStyle)),
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
@@ -259,18 +259,18 @@ const builtinToolbarConfig = {
conversionsActionGroup,
{
id: 'c.style',
actions: (
[
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
] as const
).filter(action => EmbedLinkedDocStyles.includes(action.id)),
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
].filter(action =>
EmbedLinkedDocStyles.includes(action.id as EmbedCardStyle)
),
content(ctx) {
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
if (!model) return null;
@@ -368,26 +368,26 @@ const builtinSurfaceToolbarConfig = {
conversionsActionGroup,
{
id: 'c.style',
actions: (
[
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
{
id: 'vertical',
label: 'Large vertical style',
},
{
id: 'cube',
label: 'Small vertical style',
},
] as const
).filter(action => EmbedLinkedDocStyles.includes(action.id)),
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
{
id: 'vertical',
label: 'Large vertical style',
},
{
id: 'cube',
label: 'Small vertical style',
},
].filter(action =>
EmbedLinkedDocStyles.includes(action.id as EmbedCardStyle)
),
content(ctx) {
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
if (!model) return null;
@@ -153,7 +153,7 @@ function createBuiltinToolbarConfigForExternal(
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
let style: EmbedCardStyle = model.props.style;
let { style } = model.props;
let flavour = 'affine:bookmark';
if (options?.viewType === 'card') {
@@ -227,7 +227,7 @@ function createBuiltinToolbarConfigForExternal(
if (options?.viewType !== 'embed') return;
const { flavour, styles } = options;
let style: EmbedCardStyle = model.props.style;
let { style } = model.props;
if (!styles.includes(style)) {
style =
@@ -441,11 +441,7 @@ const createBuiltinSurfaceToolbarConfigForExternal = (
let { style } = model.props;
let flavour = 'affine:bookmark';
if (
!BookmarkStyles.includes(
style as (typeof BookmarkStyles)[number]
)
) {
if (!BookmarkStyles.includes(style)) {
style = BookmarkStyles[0];
}
@@ -521,26 +517,26 @@ const createBuiltinSurfaceToolbarConfigForExternal = (
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: (
[
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
{
id: 'vertical',
label: 'Large vertical style',
},
{
id: 'cube',
label: 'Small vertical style',
},
] as const
).filter(action => EmbedGithubStyles.includes(action.id)),
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
{
id: 'vertical',
label: 'Large vertical style',
},
{
id: 'cube',
label: 'Small vertical style',
},
].filter(action =>
EmbedGithubStyles.includes(action.id as EmbedCardStyle)
),
when(ctx) {
return Boolean(ctx.getCurrentModelByType(EmbedGithubModel));
},
@@ -16,7 +16,6 @@ import {
import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit';
import { state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import {
@@ -88,12 +87,6 @@ export class FrameBlockComponent extends GfxBlockComponent<FrameBlockModel> {
this.gfx.tool.currentToolName$.value === 'frameNavigator';
const frameIndex = this.gfx.layer.getZIndex(model);
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
return html`
<div
class="affine-frame-container"
@@ -109,7 +102,6 @@ export class FrameBlockComponent extends GfxBlockComponent<FrameBlockModel> {
: `1px solid ${cssVarV2('edgeless/frame/border/default')}`,
})}
></div>
${widgets}
`;
}
@@ -186,22 +178,11 @@ export const FrameBlockInteraction =
selectable(context) {
const { model } = context;
const onTitle =
model.externalBound?.containsPoint([
context.position.x,
context.position.y,
]) ?? false;
return (
context.default(context) &&
(model.isLocked() ||
!isTransparent(model.props.background) ||
onTitle)
(model.isLocked() || !isTransparent(model.props.background))
);
},
onSelect(context) {
return context.default(context);
},
};
},
}
@@ -241,35 +241,20 @@ export class EdgelessFrameManager extends GfxExtension {
surfaceModel.elementAdded.subscribe(({ id, local }) => {
const element = surfaceModel.getElementById(id);
if (element && local) {
// The entire frame detection logic must be in microtask for timing reasons:
//
// 1. For connectors: When elementAdded fires, connectors have invalid bounds [0,0,0,0]
// because their path/bounds are calculated in a separate microtask of updateConnectorPath by connector-watcher.
// We need to wait for that calculation to complete before frame detection.
//
// 2. For shapes: Although they have valid bounds immediately, processing them in microtask
// ensures consistent timing and allows other initialization to complete first.
//
// 3. Group compatibility: Some elements may need to establish their group relationships
// before being considered for frame membership.
//
// By embedding the entire logic in microtask, we ensure:
// - Connectors have proper bounds calculated (not [0,0,0,0])
// - getFrameFromPoint() works correctly with valid element centers
// - All element initialization is complete before frame detection
const frame = this.getFrameFromPoint(element.elementBound.center);
// if the container created with a frame, skip it.
if (
isGfxGroupCompatibleModel(element) &&
frame &&
element.hasChild(frame)
) {
return;
}
// new element may intended to be added to other group
// so we need to wait for the next microtask to check if the element can be added to the frame
queueMicrotask(() => {
const frame = this.getFrameFromPoint(element.elementBound.center);
// if the container created with a frame, skip it.
if (
isGfxGroupCompatibleModel(element) &&
frame &&
element.hasChild(frame)
) {
return;
}
// Only add elements that aren't already grouped and have a valid frame
if (!element.group && frame) {
this.addElementsToFrame(frame, [element]);
}
@@ -9,10 +9,7 @@ import {
getSurfaceComponent,
} from '@blocksuite/affine-block-surface';
import { splitIntoLines } from '@blocksuite/affine-gfx-text';
import type {
EmbedCardStyle,
ShapeElementModel,
} from '@blocksuite/affine-model';
import type { ShapeElementModel } from '@blocksuite/affine-model';
import {
BookmarkStyles,
DEFAULT_NOTE_HEIGHT,
@@ -239,7 +236,7 @@ export class EdgelessClipboardController extends PageClipboard {
const options: Record<string, unknown> = {};
let flavour = 'affine:bookmark';
let style: EmbedCardStyle = BookmarkStyles[0];
let style = BookmarkStyles[0];
let isInternalLink = false;
let isLinkedBlock = false;
@@ -43,25 +43,6 @@ type RendererOptions = {
surfaceModel: SurfaceBlockModel;
};
const UpdateType = {
ELEMENT_ADDED: 'element-added',
ELEMENT_REMOVED: 'element-removed',
ELEMENT_UPDATED: 'element-updated',
VIEWPORT_CHANGED: 'viewport-changed',
SIZE_CHANGED: 'size-changed',
ZOOM_STATE_CHANGED: 'zoom-state-changed',
} as const;
type UpdateType = (typeof UpdateType)[keyof typeof UpdateType];
interface IncrementalUpdateState {
dirtyElementIds: Set<string>;
viewportDirty: boolean;
sizeDirty: boolean;
usePlaceholderDirty: boolean;
pendingUpdates: Map<string, UpdateType[]>;
}
const PLACEHOLDER_RESET_STYLES = {
border: 'none',
borderRadius: '0',
@@ -160,18 +141,6 @@ export class DomRenderer {
private _sizeUpdatedRafId: number | null = null;
private readonly _updateState: IncrementalUpdateState = {
dirtyElementIds: new Set(),
viewportDirty: false,
sizeDirty: false,
usePlaceholderDirty: false,
pendingUpdates: new Map(),
};
private _lastViewportBounds: Bound | null = null;
private _lastZoom: number | null = null;
private _lastUsePlaceholder: boolean = false;
rootElement: HTMLElement;
private readonly _elementsMap = new Map<string, HTMLElement>();
@@ -217,7 +186,6 @@ export class DomRenderer {
private _initViewport() {
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this._markViewportDirty();
this.refresh();
})
);
@@ -227,7 +195,6 @@ export class DomRenderer {
if (this._sizeUpdatedRafId) return;
this._sizeUpdatedRafId = requestConnectedFrame(() => {
this._sizeUpdatedRafId = null;
this._markSizeDirty();
this._resetSize();
this._render();
this.refresh();
@@ -241,7 +208,6 @@ export class DomRenderer {
if (this.usePlaceholder !== shouldRenderPlaceholders) {
this.usePlaceholder = shouldRenderPlaceholders;
this._markUsePlaceholderDirty();
this.refresh();
}
})
@@ -341,292 +307,6 @@ export class DomRenderer {
}
private _render() {
this._renderIncremental();
}
private _watchSurface(surfaceModel: SurfaceBlockModel) {
this._disposables.add(
surfaceModel.elementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.elementRemoved.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementDeleted.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementUpdated.subscribe(payload => {
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.elementUpdated.subscribe(payload => {
// ignore externalXYWH update cause it's updated by the renderer
if (payload.props['externalXYWH']) return;
this._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED);
this.refresh();
})
);
}
addOverlay = (overlay: Overlay) => {
overlay.setRenderer(null);
this._overlays.add(overlay);
this.refresh();
};
attach = (container: HTMLElement) => {
this._container = container;
container.append(this.rootElement);
this._resetSize();
this.refresh();
};
dispose = () => {
this._overlays.forEach(overlay => overlay.dispose());
this._overlays.clear();
this._disposables.dispose();
if (this._refreshRafId) {
cancelAnimationFrame(this._refreshRafId);
this._refreshRafId = null;
}
if (this._sizeUpdatedRafId) {
cancelAnimationFrame(this._sizeUpdatedRafId);
this._sizeUpdatedRafId = null;
}
this.rootElement.remove();
this._elementsMap.clear();
};
generateColorProperty = (color: Color, fallback?: Color) => {
return (
this.provider.generateColorProperty?.(color, fallback) ?? 'transparent'
);
};
getColorScheme = () => {
return this.provider.getColorScheme?.() ?? ColorScheme.Light;
};
getColorValue = (color: Color, fallback?: Color, real?: boolean) => {
return (
this.provider.getColorValue?.(color, fallback, real) ?? 'transparent'
);
};
getPropertyValue = (property: string) => {
return this.provider.getPropertyValue?.(property) ?? '';
};
refresh = () => {
if (this._refreshRafId !== null) return;
this._refreshRafId = requestConnectedFrame(() => {
this._refreshRafId = null;
this._render();
}, this._container);
};
removeOverlay = (overlay: Overlay) => {
if (!this._overlays.has(overlay)) {
return;
}
this._overlays.delete(overlay);
this.refresh();
};
/**
* Mark a specific element as dirty for incremental updates
* @param elementId - The ID of the element to mark as dirty
* @param updateType - The type of update (optional, defaults to ELEMENT_UPDATED)
*/
markElementDirty = (
elementId: string,
updateType: UpdateType = UpdateType.ELEMENT_UPDATED
) => {
this._markElementDirty(elementId, updateType);
};
/**
* Force a full re-render of all elements
*/
forceFullRender = () => {
this._updateState.viewportDirty = true;
this.refresh();
};
private _markElementDirty(elementId: string, updateType: UpdateType) {
this._updateState.dirtyElementIds.add(elementId);
const currentUpdates =
this._updateState.pendingUpdates.get(elementId) || [];
if (!currentUpdates.includes(updateType)) {
currentUpdates.push(updateType);
this._updateState.pendingUpdates.set(elementId, currentUpdates);
}
}
private _markViewportDirty() {
this._updateState.viewportDirty = true;
}
private _markSizeDirty() {
this._updateState.sizeDirty = true;
}
private _markUsePlaceholderDirty() {
this._updateState.usePlaceholderDirty = true;
}
private _clearUpdateState() {
this._updateState.dirtyElementIds.clear();
this._updateState.viewportDirty = false;
this._updateState.sizeDirty = false;
this._updateState.usePlaceholderDirty = false;
this._updateState.pendingUpdates.clear();
}
private _isViewportChanged(): boolean {
const { viewportBounds, zoom } = this.viewport;
if (!this._lastViewportBounds || !this._lastZoom) {
return true;
}
return (
this._lastViewportBounds.x !== viewportBounds.x ||
this._lastViewportBounds.y !== viewportBounds.y ||
this._lastViewportBounds.w !== viewportBounds.w ||
this._lastViewportBounds.h !== viewportBounds.h ||
this._lastZoom !== zoom
);
}
private _isUsePlaceholderChanged(): boolean {
return this._lastUsePlaceholder !== this.usePlaceholder;
}
private _updateLastState() {
const { viewportBounds, zoom } = this.viewport;
this._lastViewportBounds = {
x: viewportBounds.x,
y: viewportBounds.y,
w: viewportBounds.w,
h: viewportBounds.h,
} as Bound;
this._lastZoom = zoom;
this._lastUsePlaceholder = this.usePlaceholder;
}
private _renderIncremental() {
const { viewportBounds, zoom } = this.viewport;
const addedElements: HTMLElement[] = [];
const elementsToRemove: HTMLElement[] = [];
const needsFullRender =
this._isViewportChanged() ||
this._isUsePlaceholderChanged() ||
this._updateState.sizeDirty ||
this._updateState.viewportDirty ||
this._updateState.usePlaceholderDirty;
if (needsFullRender) {
this._renderFull();
this._updateLastState();
this._clearUpdateState();
return;
}
// Only update dirty elements
const elementsFromGrid = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const visibleElementIds = new Set<string>();
// 1. Update dirty elements
for (const elementModel of elementsFromGrid) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
if (
display &&
intersects(getBoundWithRotation(elementModel), viewportBounds)
) {
visibleElementIds.add(elementModel.id);
// Only update dirty elements
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
}
}
}
// 2. Remove elements that are no longer in the grid
for (const elementId of this._updateState.dirtyElementIds) {
const updateTypes = this._updateState.pendingUpdates.get(elementId) || [];
if (
updateTypes.includes(UpdateType.ELEMENT_REMOVED) ||
!visibleElementIds.has(elementId)
) {
const domElem = this._elementsMap.get(elementId);
if (domElem) {
domElem.remove();
this._elementsMap.delete(elementId);
elementsToRemove.push(domElem);
}
}
}
// 3. Notify changes
if (addedElements.length > 0 || elementsToRemove.length > 0) {
this.elementsUpdated.next({
elements: Array.from(this._elementsMap.values()),
added: addedElements,
removed: elementsToRemove,
});
}
this._updateLastState();
this._clearUpdateState();
}
private _renderFull() {
const { viewportBounds, zoom } = this.viewport;
const addedElements: HTMLElement[] = [];
const elementsToRemove: HTMLElement[] = [];
@@ -707,4 +387,100 @@ export class DomRenderer {
});
}
}
private _watchSurface(surfaceModel: SurfaceBlockModel) {
this._disposables.add(
surfaceModel.elementAdded.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.elementRemoved.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementAdded.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementDeleted.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementUpdated.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.elementUpdated.subscribe(payload => {
// ignore externalXYWH update cause it's updated by the renderer
if (payload.props['externalXYWH']) return;
this.refresh();
})
);
}
addOverlay(overlay: Overlay) {
overlay.setRenderer(null);
this._overlays.add(overlay);
this.refresh();
}
attach(container: HTMLElement) {
this._container = container;
container.append(this.rootElement);
this._resetSize();
this.refresh();
}
dispose(): void {
this._overlays.forEach(overlay => overlay.dispose());
this._overlays.clear();
this._disposables.dispose();
if (this._refreshRafId) {
cancelAnimationFrame(this._refreshRafId);
this._refreshRafId = null;
}
if (this._sizeUpdatedRafId) {
cancelAnimationFrame(this._sizeUpdatedRafId);
this._sizeUpdatedRafId = null;
}
this.rootElement.remove();
this._elementsMap.clear();
}
generateColorProperty(color: Color, fallback?: Color) {
return (
this.provider.generateColorProperty?.(color, fallback) ?? 'transparent'
);
}
getColorScheme() {
return this.provider.getColorScheme?.() ?? ColorScheme.Light;
}
getColorValue(color: Color, fallback?: Color, real?: boolean) {
return (
this.provider.getColorValue?.(color, fallback, real) ?? 'transparent'
);
}
getPropertyValue(property: string) {
return this.provider.getPropertyValue?.(property) ?? '';
}
refresh() {
if (this._refreshRafId !== null) return;
this._refreshRafId = requestConnectedFrame(() => {
this._refreshRafId = null;
this._render();
}, this._container);
}
removeOverlay(overlay: Overlay) {
if (!this._overlays.has(overlay)) {
return;
}
this._overlays.delete(overlay);
this.refresh();
}
}
@@ -29,6 +29,12 @@ export class OpenDocDropdownMenu extends SignalWatcher(
gap: unset !important;
}
editor-icon-button {
.label {
font-weight: 400;
}
}
div[data-orientation] {
width: 264px;
gap: 4px;
@@ -9,7 +9,6 @@ const toolbarColorKeys: Array<keyof AffineCssVariables> = [
'--affine-background-overlay-panel-color',
'--affine-v2-layer-background-overlayPanel' as never,
'--affine-v2-layer-insideBorder-blackBorder' as never,
'--affine-v2-icon-primary' as never,
'--affine-background-error-color',
'--affine-background-primary-color',
'--affine-background-tertiary-color',
@@ -16,6 +16,5 @@ export const renderFilterBar = (props: DataViewWidgetProps) => {
.vars="${filterTrait.view.vars$}"
.filterGroup="${filterTrait.filter$}"
.onChange="${filterTrait.filterSet}"
.dataViewLogic="${props.dataViewLogic}"
></filter-bar>`;
};
@@ -16,7 +16,6 @@ import { property } from 'lit/decorators.js';
import type { Variable } from '../../../core/expression/types.js';
import type { Filter, FilterGroup } from '../../../core/filter/types.js';
import { popCreateFilter } from '../../../core/index.js';
import type { DataViewUILogicBase } from '../../../core/view/data-view-base.js';
import { popFilterGroup } from './group-panel-view.js';
export class FilterBar extends SignalWatcher(ShadowlessElement) {
@@ -100,7 +99,6 @@ export class FilterBar extends SignalWatcher(ShadowlessElement) {
requestAnimationFrame(() => {
this.expandGroup(element, index);
});
this.dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
});
};
@@ -208,9 +206,6 @@ export class FilterBar extends SignalWatcher(ShadowlessElement) {
@property({ attribute: false })
accessor vars!: ReadonlySignal<Variable[]>;
@property({ attribute: false })
accessor dataViewLogic!: DataViewUILogicBase;
}
declare global {
@@ -26,10 +26,7 @@ import { repeat } from 'lit/directives/repeat.js';
import type { Variable } from '../../../core/expression/types.js';
import type { FilterTrait } from '../../../core/filter/trait.js';
import type { Filter, FilterGroup } from '../../../core/filter/types.js';
import {
type DataViewUILogicBase,
popCreateFilter,
} from '../../../core/index.js';
import { popCreateFilter } from '../../../core/index.js';
import {
type FilterGroupView,
getDepth,
@@ -378,7 +375,6 @@ export const popFilterRoot = (
props: {
filterTrait: FilterTrait;
onBack: () => void;
dataViewLogic: DataViewUILogicBase;
}
) => {
const filterTrait = props.filterTrait;
@@ -418,10 +414,6 @@ export const popFilterRoot = (
...value,
conditions: [...value.conditions, filter],
});
props.dataViewLogic.eventTrace(
'CreateDatabaseFilter',
{}
);
},
},
{ middleware: subMenuMiddleware }
@@ -75,7 +75,6 @@ export class DataViewHeaderToolsFilter extends WidgetBase {
conditions: [filter],
};
this.toggleShowFilter(true);
this.dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
}
);
@@ -145,16 +145,13 @@ const createSettingMenus = (
popFilterRoot(target, {
filterTrait: filterTrait,
onBack: reopen,
dataViewLogic: dataViewLogic,
});
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
});
} else {
popFilterRoot(target, {
filterTrait: filterTrait,
onBack: reopen,
dataViewLogic: dataViewLogic,
});
}
},
@@ -192,14 +192,10 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
this._updateTitleInMeta();
this.requestUpdate();
};
if (this._rootModel) {
const rootModel = this._rootModel;
rootModel.props.title.yText.observe(updateMetaTitle);
this._disposables.add(() => {
rootModel.props.title.yText.unobserve(updateMetaTitle);
});
}
this._rootModel?.props.title.yText.observe(updateMetaTitle);
this._disposables.add(() => {
this._rootModel?.props.title.yText.unobserve(updateMetaTitle);
});
}
override render() {
@@ -1,6 +1,6 @@
import { OverlayIdentifier } from '@blocksuite/affine-block-surface';
import { MindmapElementModel } from '@blocksuite/affine-model';
import { type Bound } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import {
type DragExtensionInitializeContext,
type ExtensionDragMoveContext,
@@ -74,63 +74,47 @@ export class SnapExtension extends InteractivityExtension {
return {};
}
let alignBound: Bound | null = null;
return {
onResizeStart(context) {
snapOverlay.setMovingElements(context.elements);
alignBound = snapOverlay.setMovingElements(context.elements);
},
onResizeMove(context) {
const {
handle,
originalBound,
scaleX,
scaleY,
handleSign,
currentHandlePos,
elements,
} = context;
const rotate = elements.length > 1 ? 0 : elements[0].rotate;
const alignDirection: ('vertical' | 'horizontal')[] = [];
let switchDirection = false;
let nx = handleSign.x;
let ny = handleSign.y;
if (handle.length > 6) {
alignDirection.push('vertical', 'horizontal');
} else if (rotate % 90 === 0) {
nx =
handleSign.x * Math.cos((rotate / 180) * Math.PI) -
handleSign.y * Math.sin((rotate / 180) * Math.PI);
ny =
handleSign.x * Math.sin((rotate / 180) * Math.PI) +
handleSign.y * Math.cos((rotate / 180) * Math.PI);
if (Math.abs(nx) > Math.abs(ny)) {
alignDirection.push('horizontal');
} else {
alignDirection.push('vertical');
}
if (rotate % 180 !== 0) {
switchDirection = true;
}
if (!alignBound || alignBound.w === 0 || alignBound.h === 0) {
return;
}
if (alignDirection.length > 0) {
const rst = snapOverlay.alignResize(
currentHandlePos,
alignDirection
const { handle, handleSign, lockRatio } = context;
let { dx, dy } = context;
if (lockRatio) {
const min = Math.min(
Math.abs(dx / alignBound.w),
Math.abs(dy / alignBound.h)
);
const dx = switchDirection ? ny * rst.dy : nx * rst.dx;
const dy = switchDirection ? nx * rst.dx : ny * rst.dy;
context.suggest({
scaleX: scaleX + dx / originalBound.w,
scaleY: scaleY + dy / originalBound.h,
});
dx = min * Math.sign(dx) * alignBound.w;
dy = min * Math.sign(dy) * alignBound.h;
}
const currentBound = new Bound(
alignBound.x +
(handle.includes('left') ? -dx * handleSign.xSign : 0),
alignBound.y +
(handle.includes('top') ? -dy * handleSign.ySign : 0),
Math.abs(alignBound.w + dx * handleSign.xSign),
Math.abs(alignBound.h + dy * handleSign.ySign)
);
const alignRst = snapOverlay.align(currentBound);
context.suggest({
dx: alignRst.dx + context.dx,
dy: alignRst.dy + context.dy,
});
},
onResizeEnd() {
alignBound = null;
snapOverlay.clear();
},
};
@@ -3,7 +3,7 @@ import {
ConnectorElementModel,
MindmapElementModel,
} from '@blocksuite/affine-model';
import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx';
import { almostEqual, Bound, Point } from '@blocksuite/global/gfx';
import type { GfxModel } from '@blocksuite/std/gfx';
interface Distance {
@@ -586,60 +586,6 @@ export class SnapOverlay extends Overlay {
);
}
alignResize(position: IVec, direction: ('vertical' | 'horizontal')[]) {
const rst = { dx: 0, dy: 0 };
const { viewport } = this.gfx;
const threshold = ALIGN_THRESHOLD / viewport.zoom;
const searchBound = new Bound(
position[0] - threshold / 2,
position[1] - threshold / 2,
threshold,
threshold
);
const alignBound = new Bound(position[0], position[1], 0, 0);
this._intraGraphicAlignLines = {
horizontal: [],
vertical: [],
};
this._distributedAlignLines = [];
this._updateAlignCandidates(searchBound);
for (const other of this._referenceBounds.all) {
const closestDistances = this._calculateClosestDistances(
alignBound,
other
);
if (
direction.includes('horizontal') &&
closestDistances.horiz &&
(!this._intraGraphicAlignLines.horizontal.length ||
Math.abs(closestDistances.horiz.distance) < Math.abs(rst.dx))
) {
this._updateXAlignPoint(rst, alignBound, other, closestDistances);
}
if (
direction.includes('vertical') &&
closestDistances.vert &&
(!this._intraGraphicAlignLines.vertical.length ||
Math.abs(closestDistances.vert.distance) < Math.abs(rst.dy))
) {
this._updateYAlignPoint(rst, alignBound, other, closestDistances);
}
}
this._intraGraphicAlignLines.horizontal =
this._intraGraphicAlignLines.horizontal.slice(0, 1);
this._intraGraphicAlignLines.vertical =
this._intraGraphicAlignLines.vertical.slice(0, 1);
this._renderer?.refresh();
return rst;
}
align(bound: Bound): { dx: number; dy: number } {
const rst = { dx: 0, dy: 0 };
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
@@ -9,35 +9,18 @@ function applyShapeSpecificStyles(
element: HTMLElement,
zoom: number
) {
// Reset properties that might be set by different shape types
element.style.removeProperty('clip-path');
element.style.removeProperty('border-radius');
// Clear DOM for shapes that don't use SVG, or if type changes from SVG-based to non-SVG-based
if (model.shapeType !== 'diamond' && model.shapeType !== 'triangle') {
while (element.firstChild) element.firstChild.remove();
if (model.shapeType === 'rect') {
const w = model.w * zoom;
const h = model.h * zoom;
const r = model.radius ?? 0;
const borderRadius =
r < 1 ? `${Math.min(w * r, h * r)}px` : `${r * zoom}px`;
element.style.borderRadius = borderRadius;
} else if (model.shapeType === 'ellipse') {
element.style.borderRadius = '50%';
} else {
element.style.borderRadius = '';
}
switch (model.shapeType) {
case 'rect': {
const w = model.w * zoom;
const h = model.h * zoom;
const r = model.radius ?? 0;
const borderRadius =
r < 1 ? `${Math.min(w * r, h * r)}px` : `${r * zoom}px`;
element.style.borderRadius = borderRadius;
break;
}
case 'ellipse':
element.style.borderRadius = '50%';
break;
case 'diamond':
element.style.clipPath = 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)';
break;
case 'triangle':
element.style.clipPath = 'polygon(50% 0%, 100% 100%, 0% 100%)';
break;
}
// No 'else' needed to clear styles, as they are reset at the beginning of the function.
}
function applyBorderStyles(
@@ -95,9 +78,6 @@ export const shapeDomRenderer = (
renderer: DomRenderer
): void => {
const { zoom } = renderer.viewport;
const unscaledWidth = model.w;
const unscaledHeight = model.h;
const fillColor = renderer.getColorValue(
model.fillColor,
DefaultTheme.shapeFillColor,
@@ -109,80 +89,17 @@ export const shapeDomRenderer = (
true
);
element.style.width = `${unscaledWidth * zoom}px`;
element.style.height = `${unscaledHeight * zoom}px`;
element.style.boxSizing = 'border-box';
element.style.width = `${model.w * zoom}px`;
element.style.height = `${model.h * zoom}px`;
// Apply shape-specific clipping, border-radius, and potentially clear innerHTML
applyShapeSpecificStyles(model, element, zoom);
if (model.shapeType === 'diamond' || model.shapeType === 'triangle') {
// For diamond and triangle, fill and border are handled by inline SVG
element.style.border = 'none'; // Ensure no standard CSS border interferes
element.style.backgroundColor = 'transparent'; // Host element is transparent
const strokeW = model.strokeWidth;
const halfStroke = strokeW / 2; // Calculate half stroke width for point adjustment
let svgPoints = '';
if (model.shapeType === 'diamond') {
// Adjusted points for diamond
svgPoints = [
`${unscaledWidth / 2},${halfStroke}`,
`${unscaledWidth - halfStroke},${unscaledHeight / 2}`,
`${unscaledWidth / 2},${unscaledHeight - halfStroke}`,
`${halfStroke},${unscaledHeight / 2}`,
].join(' ');
} else {
// triangle
// Adjusted points for triangle
svgPoints = [
`${unscaledWidth / 2},${halfStroke}`,
`${unscaledWidth - halfStroke},${unscaledHeight - halfStroke}`,
`${halfStroke},${unscaledHeight - halfStroke}`,
].join(' ');
}
// Determine if stroke should be visible and its color
const finalStrokeColor =
model.strokeStyle !== 'none' && strokeW > 0 ? strokeColor : 'transparent';
// Determine dash array, only if stroke is visible and style is 'dash'
const finalStrokeDasharray =
model.strokeStyle === 'dash' && finalStrokeColor !== 'transparent'
? '12, 12'
: 'none';
// Determine fill color
const finalFillColor = model.filled ? fillColor : 'transparent';
// Build SVG safely with DOM-API
const SVG_NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`);
svg.setAttribute('preserveAspectRatio', 'none');
const polygon = document.createElementNS(SVG_NS, 'polygon');
polygon.setAttribute('points', svgPoints);
polygon.setAttribute('fill', finalFillColor);
polygon.setAttribute('stroke', finalStrokeColor);
polygon.setAttribute('stroke-width', String(strokeW));
if (finalStrokeDasharray !== 'none') {
polygon.setAttribute('stroke-dasharray', finalStrokeDasharray);
}
svg.append(polygon);
// Replace existing children to avoid memory leaks
element.replaceChildren(svg);
} else {
// Standard rendering for other shapes (e.g., rect, ellipse)
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
}
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
applyBorderStyles(model, element, strokeColor, zoom);
applyTransformStyles(model, element);
element.style.boxSizing = 'border-box';
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
manageClassNames(model, element);
@@ -188,8 +188,6 @@ export class AffineLatexNode extends SignalWatcher(
this._editorAbortController?.abort();
this._editorAbortController = new AbortController();
blockComponent.selection.setGroup('note', []);
const portal = createLitPortal({
template: html`<latex-editor-menu
.std=${this.std}
@@ -30,12 +30,11 @@ import { AttachmentBlockTransformer } from './attachment-transformer.js';
*/
type BackwardCompatibleUndefined = undefined;
export const AttachmentBlockStyles = [
export const AttachmentBlockStyles: EmbedCardStyle[] = [
'cubeThick',
'horizontalThin',
'pdf',
'citation',
] as const satisfies EmbedCardStyle[];
] as const;
export type AttachmentBlockProps = {
name: string;
@@ -15,13 +15,13 @@ import type {
LinkPreviewData,
} from '../../utils/index.js';
export const BookmarkStyles = [
export const BookmarkStyles: EmbedCardStyle[] = [
'vertical',
'horizontal',
'list',
'cube',
'citation',
] as const satisfies EmbedCardStyle[];
] as const;
export type BookmarkBlockProps = {
style: (typeof BookmarkStyles)[number];
@@ -8,7 +8,7 @@ export type EmbedFigmaBlockUrlData = {
description: string | null;
};
export const EmbedFigmaStyles = ['figma'] as const satisfies EmbedCardStyle[];
export const EmbedFigmaStyles: EmbedCardStyle[] = ['figma'] as const;
export type EmbedFigmaBlockProps = {
style: (typeof EmbedFigmaStyles)[number];
@@ -13,12 +13,12 @@ export type EmbedGithubBlockUrlData = {
assignees: string[] | null;
};
export const EmbedGithubStyles = [
export const EmbedGithubStyles: EmbedCardStyle[] = [
'vertical',
'horizontal',
'list',
'cube',
] as const satisfies EmbedCardStyle[];
] as const;
export type EmbedGithubBlockProps = {
style: (typeof EmbedGithubStyles)[number];
@@ -3,7 +3,7 @@ import { BlockModel } from '@blocksuite/store';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedHtmlStyles = ['html'] as const satisfies EmbedCardStyle[];
export const EmbedHtmlStyles: EmbedCardStyle[] = ['html'] as const;
export type EmbedHtmlBlockProps = {
style: (typeof EmbedHtmlStyles)[number];
@@ -7,7 +7,7 @@ import { BlockModel } from '@blocksuite/store';
import { type EmbedCardStyle } from '../../../utils/index.js';
export const EmbedIframeStyles = ['figma'] as const satisfies EmbedCardStyle[];
export const EmbedIframeStyles: EmbedCardStyle[] = ['figma'] as const;
export type EmbedIframeBlockProps = {
url: string; // the original url that user input
@@ -4,17 +4,17 @@ import type { ReferenceInfo } from '../../../consts/doc.js';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedLinkedDocStyles = [
export const EmbedLinkedDocStyles: EmbedCardStyle[] = [
'vertical',
'horizontal',
'list',
'cube',
'horizontalThin',
'citation',
] as const satisfies EmbedCardStyle[];
];
export type EmbedLinkedDocBlockProps = {
style: (typeof EmbedLinkedDocStyles)[number];
style: EmbedCardStyle;
caption: string | null;
footnoteIdentifier: string | null;
} & ReferenceInfo;
@@ -10,7 +10,7 @@ export type EmbedLoomBlockUrlData = {
description: string | null;
};
export const EmbedLoomStyles = ['video'] as const satisfies EmbedCardStyle[];
export const EmbedLoomStyles: EmbedCardStyle[] = ['video'] as const;
export type EmbedLoomBlockProps = {
style: (typeof EmbedLoomStyles)[number];
@@ -5,9 +5,7 @@ import type { ReferenceInfo } from '../../../consts/doc.js';
import type { EmbedCardStyle } from '../../../utils/index.js';
import { defineEmbedModel } from '../../../utils/index.js';
export const EmbedSyncedDocStyles = [
'syncedDoc',
] as const satisfies EmbedCardStyle[];
export const EmbedSyncedDocStyles: EmbedCardStyle[] = ['syncedDoc'];
export type EmbedSyncedDocBlockProps = {
style: EmbedCardStyle;
@@ -1,7 +1,6 @@
import type { GfxModel } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { BookmarkBlockModel } from '../bookmark';
import { EmbedFigmaModel } from './figma';
import { EmbedGithubModel } from './github';
import type { EmbedHtmlModel } from './html';
@@ -31,10 +30,7 @@ export type EmbedCardModel = InstanceType<
ExternalEmbedModel | InternalEmbedModel
>;
export type LinkableEmbedModel =
| EmbedCardModel
| EmbedIframeBlockModel
| BookmarkBlockModel;
export type LinkableEmbedModel = EmbedCardModel | EmbedIframeBlockModel;
export type BuiltInEmbedModel = EmbedCardModel | EmbedHtmlModel;
@@ -13,7 +13,7 @@ export type EmbedYoutubeBlockUrlData = {
creatorImage: string | null;
};
export const EmbedYoutubeStyles = ['video'] as const satisfies EmbedCardStyle[];
export const EmbedYoutubeStyles: EmbedCardStyle[] = ['video'] as const;
export type EmbedYoutubeBlockProps = {
style: (typeof EmbedYoutubeStyles)[number];
@@ -80,8 +80,4 @@ describe('isValidUrl: determining whether a URL is valid is very complicated', (
// See also https://stackoverflow.com/questions/9238640/how-long-can-a-tld-possibly-be#:~:text=Longest%20TLD%20up%20to%20date,17%20when%20decoded%20%5Bverm%C3%B6gensberatung%5D.
expect(isValidUrl('example.xn--vermgensberatung-pwb')).toEqual(false);
});
test('should allow ip address url when origin is same', () => {
expect(isValidUrl('http://127.0.0.1', 'http://127.0.0.1')).toEqual(true);
});
});
+5 -12
View File
@@ -11,9 +11,6 @@ const ALLOWED_SCHEMES = new Set([
// https://publicsuffix.org/
const TLD_REGEXP = /(?:\.[a-zA-Z]+)?(\.[a-zA-Z]{2,})$/;
const IPV4_ADDR_REGEXP =
/^(25[0-5]|2[0-4]\d|[01]?\d\d?)(\.(25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/;
const toURL = (str: string) => {
try {
if (!URL.canParse(str)) return null;
@@ -24,20 +21,16 @@ const toURL = (str: string) => {
}
};
function resolveURL(str: string, baseUrl: string, padded = false) {
function resolveURL(str: string) {
const url = toURL(str);
if (!url) return null;
const protocol = url.protocol.substring(0, url.protocol.length - 1);
const hostname = url.hostname;
const origin = url.origin;
let allowed = ALLOWED_SCHEMES.has(protocol);
if (allowed && hostname.includes('.')) {
allowed =
origin === baseUrl ||
TLD_REGEXP.test(hostname) ||
(padded ? false : IPV4_ADDR_REGEXP.test(hostname));
allowed = TLD_REGEXP.test(hostname);
}
return { url, allowed };
@@ -75,10 +68,10 @@ export function normalizeUrl(str: string) {
*
* For more detail see https://www.ietf.org/rfc/rfc1738.txt
*/
export function isValidUrl(str: string, baseUrl = location.origin) {
export function isValidUrl(str: string) {
str = str.trim();
let result = resolveURL(str, baseUrl);
let result = resolveURL(str);
if (result && !result.allowed) return false;
@@ -87,7 +80,7 @@ export function isValidUrl(str: string, baseUrl = location.origin) {
if (!hasScheme) {
const dotIdx = str.indexOf('.');
if (dotIdx > 0 && dotIdx < str.length - 1) {
result = resolveURL(`https://${str}`, baseUrl, true);
result = resolveURL(`https://${str}`);
}
}
}
@@ -1,56 +0,0 @@
import {
AttachmentBlockModel,
BookmarkBlockModel,
EmbedGithubModel,
EmbedLinkedDocModel,
NoteBlockModel,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { TransformerMiddleware } from '@blocksuite/store';
export const cardStyleUpdater =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
slots.beforeImport.subscribe(payload => {
if (payload.type !== 'block' || !payload.parent) return;
const parentModel = std.store.getModelById(payload.parent);
if (!matchModels(parentModel, [NoteBlockModel])) return;
// TODO(@L-Sun): Refactor this after refactor `store.moveBlocks`
// Currently, drag a block will use store.moveBlocks to update the tree of blocks
// but the instance of it is not changed.
// So change the style of snapshot.props in the middleware is not working.
// Instead, we can change the style of the model instance in the middleware,
const model = std.store.getModelById(payload.snapshot.id);
if (!model) return;
if (model instanceof AttachmentBlockModel) {
std.store.updateBlock(model, {
style: 'horizontalThin',
});
return;
}
if (model instanceof BookmarkBlockModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
if (model instanceof EmbedGithubModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
if (model instanceof EmbedLinkedDocModel) {
std.store.updateBlock(model, {
style: 'horizontal',
});
return;
}
});
};
@@ -76,7 +76,6 @@ import last from 'lodash-es/last';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import { PreviewHelper } from '../helpers/preview-helper.js';
import { gfxBlocksFilter } from '../middleware/blocks-filter.js';
import { cardStyleUpdater } from '../middleware/card-style-updater.js';
import { newIdCrossDoc } from '../middleware/new-id-cross-doc.js';
import { reorderList } from '../middleware/reorder-list';
import {
@@ -1434,7 +1433,6 @@ export class DragEventWatcher {
newIdCrossDoc(std),
reorderList(std),
surfaceRefToEmbed(std),
cardStyleUpdater(std),
];
if (selectedIds) {
@@ -374,8 +374,6 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
type: 'resize' | 'rotate';
angle: number;
handle: ResizeHandle;
flipX?: boolean;
flipY?: boolean;
pure?: boolean;
}) => {
if (!options) {
@@ -383,25 +381,8 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
return 'default';
}
const { type, angle, flipX, flipY } = options;
const { type, angle, handle } = options;
let cursor: CursorType = 'default';
let handle: ResizeHandle = options.handle;
if (flipX) {
handle = (
handle.includes('left')
? handle.replace('left', 'right')
: handle.replace('right', 'left')
) as ResizeHandle;
}
if (flipY) {
handle = (
handle.includes('top')
? handle.replace('top', 'bottom')
: handle.replace('bottom', 'top')
) as ResizeHandle;
}
if (type === 'rotate') {
cursor = generateCursorUrl(angle, handle);
@@ -645,7 +626,7 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
onResizeStart: () => {
this._mode = 'resize';
},
onResizeUpdate: ({ lockRatio, scaleX, scaleY, exceed }) => {
onResizeUpdate: ({ lockRatio, scaleX, exceed }) => {
if (lockRatio) {
this._scaleDirection = handle;
this._scalePercent = `${Math.round(scaleX * 100)}%`;
@@ -661,8 +642,6 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
type: 'resize',
angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0),
handle,
flipX: scaleX < 0,
flipY: scaleY < 0,
});
},
onResizeEnd: () => {
@@ -673,14 +652,6 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
}
},
option => {
if (
['resize', 'rotate'].includes(
interaction.activeInteraction$.value?.type ?? ''
)
) {
return '';
}
return this._updateCursor({
...option,
angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0),
@@ -1,21 +1,43 @@
import { type FrameBlockModel } from '@blocksuite/affine-model';
import { FrameBlockModel, type RootBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
import type { AffineFrameTitle } from './frame-title.js';
export const AFFINE_FRAME_TITLE_WIDGET = 'affine-frame-title-widget';
export class AffineFrameTitleWidget extends WidgetComponent<FrameBlockModel> {
export class AffineFrameTitleWidget extends WidgetComponent<RootBlockModel> {
private get _frames() {
return Object.values(this.store.blocks.value)
.map(({ model }) => model)
.filter(model => model instanceof FrameBlockModel);
}
getFrameTitle(frame: FrameBlockModel | string) {
const id = typeof frame === 'string' ? frame : frame.id;
const frameTitle = this.shadowRoot?.querySelector(
`affine-frame-title[data-id="${id}"]`
) as AffineFrameTitle | null;
return frameTitle;
}
override render() {
return html`<affine-frame-title
.model=${this.model}
data-id=${this.model.id}
></affine-frame-title>`;
return repeat(
this._frames,
({ id }) => id,
frame =>
html`<affine-frame-title
.model=${frame}
data-id=${frame.id}
></affine-frame-title>`
);
}
}
export const frameTitleWidget = WidgetViewExtension(
'affine:frame',
'affine:page',
AFFINE_FRAME_TITLE_WIDGET,
literal`${unsafeStatic(AFFINE_FRAME_TITLE_WIDGET)}`
);
@@ -14,7 +14,6 @@ import {
AFFINE_FRAME_TITLE_WIDGET,
type AffineFrameTitleWidget,
} from './affine-frame-title-widget';
import type { AffineFrameTitle } from './frame-title';
import { frameTitleStyleVars } from './styles';
export class EdgelessFrameTitleEditor extends WithDisposable(
@@ -136,13 +135,12 @@ export class EdgelessFrameTitleEditor extends WithDisposable(
const frameTitleWidget = this.edgeless.std.view.getWidget(
AFFINE_FRAME_TITLE_WIDGET,
this.frameModel.id
rootBlockId
) as AffineFrameTitleWidget | null;
if (!frameTitleWidget) return nothing;
const frameTitle =
frameTitleWidget.querySelector<AffineFrameTitle>('affine-frame-title');
const frameTitle = frameTitleWidget.getFrameTitle(this.frameModel);
const colors = frameTitle?.colors ?? {
background: cssVarV2('edgeless/frame/background/white'),
@@ -142,10 +142,12 @@ export class AffineFrameTitle extends SignalWatcher(
}px)`,
];
const anchor = this.gfx.viewport.toViewCoord(bound.x, bound.y);
this.style.display = '';
this.style.setProperty('--bg-color', this.colors.background);
this.style.left = '0px';
this.style.top = '0px';
this.style.left = `${anchor[0]}px`;
this.style.top = `${anchor[1]}px`;
this.style.display = hidden ? 'none' : 'flex';
this.style.transform = transformOperation.join(' ');
this.style.maxWidth = `${maxWidth}px`;
@@ -203,6 +205,18 @@ export class AffineFrameTitle extends SignalWatcher(
})
);
_disposables.add(
on(this, 'click', evt => {
if (evt.shiftKey) {
this.gfx.selection.toggle(this.model);
} else {
this.gfx.selection.set({
elements: [this.model.id],
});
}
})
);
_disposables.add(
on(this, 'dblclick', () => {
const edgeless = this.std.view.getBlock(this.std.store.root?.id || '');
@@ -566,29 +566,23 @@ Optional flag to insert before sibling
### updateBlock()
> **updateBlock**\<`T`\>(`modelOrId`, `callBackOrProps`): `void`
> **updateBlock**(`modelOrId`, `callBackOrProps`): `void`
Updates a block's properties or executes a callback in a transaction
#### Type Parameters
##### T
`T` *extends* `BlockModel`\<`object`\> = `BlockModel`\<`object`\>
#### Parameters
##### modelOrId
The block model or block ID to update
`string` | `T`
`string` | `BlockModel`\<`object`\>
##### callBackOrProps
Either a callback function to execute or properties to update
() => `void` | `Partial`\<`BlockProps` \| `PropsOfModel`\<`T`\> & `BlockSysProps`\>
`Partial`\<`BlockProps`\> | () => `void`
#### Returns
@@ -947,34 +947,23 @@ export class InteractivityManager extends GfxExtension {
...options,
lockRatio,
elements,
onResizeMove: ({
scaleX,
scaleY,
originalBound,
handleSign,
handlePos,
currentHandlePos,
lockRatio,
}) => {
onResizeMove: ({ dx, dy, handleSign, lockRatio }) => {
const suggested: {
scaleX: number;
scaleY: number;
dx: number;
dy: number;
priority?: number;
}[] = [];
const suggest = (distance: { scaleX: number; scaleY: number }) => {
const suggest = (distance: { dx: number; dy: number }) => {
suggested.push(distance);
};
extensionHandlers.forEach(ext => {
ext.onResizeMove?.({
scaleX,
scaleY,
dx,
dy,
elements,
handle,
handleSign,
handlePos,
originalBound,
currentHandlePos,
handle,
lockRatio,
suggest,
});
@@ -984,9 +973,9 @@ export class InteractivityManager extends GfxExtension {
return (a.priority ?? 0) - (b.priority ?? 0);
});
return last(suggested) ?? { scaleX, scaleY };
return last(suggested) ?? { dx, dy };
},
onResizeStart: ({ handleSign, handlePos, data }) => {
onResizeStart: ({ data }) => {
this.activeInteraction$.value = {
type: 'resize',
elements,
@@ -995,8 +984,6 @@ export class InteractivityManager extends GfxExtension {
ext.onResizeStart?.({
elements,
handle,
handlePos,
handleSign,
});
});
@@ -1058,15 +1045,13 @@ export class InteractivityManager extends GfxExtension {
options.onResizeUpdate?.({ scaleX, scaleY, lockRatio, exceed });
},
onResizeEnd: ({ handleSign, handlePos, data }) => {
onResizeEnd: ({ data }) => {
this.activeInteraction$.value = null;
extensionHandlers.forEach(ext => {
ext.onResizeEnd?.({
elements,
handle,
handlePos,
handleSign,
});
});
options.onResizeEnd?.();
@@ -2,7 +2,6 @@ import {
Bound,
getCommonBoundWithRotation,
type IBound,
type IPoint,
type IVec,
} from '@blocksuite/global/gfx';
@@ -30,7 +29,7 @@ export const DEFAULT_HANDLES: ResizeHandle[] = [
'bottom',
];
type ReadonlyIBound = Readonly<Required<IBound>>;
type ElementInitialSnapshot = Readonly<Required<IBound>>;
export interface OptionResize {
elements: GfxModel[];
@@ -38,18 +37,16 @@ export interface OptionResize {
lockRatio: boolean;
event: PointerEvent;
onResizeMove: (payload: {
scaleX: number;
scaleY: number;
dx: number;
dy: number;
originalBound: IBound;
handleSign: IPoint;
handlePos: IVec;
currentHandlePos: IVec;
handleSign: {
xSign: number;
ySign: number;
};
lockRatio: boolean;
}) => { scaleX: number; scaleY: number };
}) => { dx: number; dy: number };
onResizeUpdate: (payload: {
lockRatio: boolean;
scaleX: number;
@@ -62,16 +59,8 @@ export interface OptionResize {
matrix: DOMMatrix;
}[];
}) => void;
onResizeStart?: (payload: {
handlePos: IVec;
handleSign: IPoint;
data: { model: GfxModel }[];
}) => void;
onResizeEnd?: (payload: {
handlePos: IVec;
handleSign: IPoint;
data: { model: GfxModel }[];
}) => void;
onResizeStart?: (payload: { data: { model: GfxModel }[] }) => void;
onResizeEnd?: (payload: { data: { model: GfxModel }[] }) => void;
}
export type RotateOption = {
@@ -106,102 +95,11 @@ export class ResizeController {
this.gfx = option.gfx;
}
getCoordsTransform(originalBound: IBound, handle: ResizeHandle) {
const { x: xSign, y: ySign } = this.getHandleSign(handle);
const pivot = new DOMPoint(
originalBound.x + ((-xSign + 1) / 2) * originalBound.w,
originalBound.y + ((-ySign + 1) / 2) * originalBound.h
);
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
const toLocalRotatedM = new DOMMatrix()
.translate(-pivot.x, -pivot.y)
.translate(
originalBound.w / 2 + originalBound.x,
originalBound.h / 2 + originalBound.y
)
.rotate(-(originalBound.rotate ?? 0))
.translate(
-(originalBound.w / 2 + originalBound.x),
-(originalBound.h / 2 + originalBound.y)
);
const toLocal = (p: DOMPoint, withRotation: boolean = false) =>
p.matrixTransform(withRotation ? toLocalRotatedM : toLocalM);
const toModel = (p: DOMPoint) =>
p.matrixTransform(toLocalRotatedM.inverse());
const handlePos = toModel(
new DOMPoint(originalBound.w * xSign, originalBound.h * ySign)
);
return {
xSign,
ySign,
originalBound,
toLocalM,
toLocalRotatedM,
toLocal,
toModel,
handlePos: [handlePos.x, handlePos.y] as IVec,
};
}
getScaleFromDelta(
transform: ReturnType<ResizeController['getCoordsTransform']>,
delta: { dx: number; dy: number },
handleStartPos: IVec,
lockRatio: boolean
) {
const { originalBound, xSign, ySign, toModel, toLocal } = transform;
const currentPos = toLocal(
new DOMPoint(handleStartPos[0] + delta.dx, handleStartPos[1] + delta.dy),
true
);
let scaleX = xSign ? currentPos.x / (originalBound.w * xSign) : 1;
let scaleY = ySign ? currentPos.y / (originalBound.h * ySign) : 1;
if (lockRatio) {
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
}
const finalHandlePos = toModel(
new DOMPoint(
originalBound.w * xSign * scaleX,
originalBound.h * ySign * scaleY
)
);
return {
scaleX,
scaleY,
handlePos: [finalHandlePos.x, finalHandlePos.y] as IVec,
};
}
getScaleMatrix(
{ scaleX, scaleY }: { scaleX: number; scaleY: number },
lockRatio: boolean
) {
if (lockRatio) {
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
}
return {
scaleX,
scaleY,
scaleM: new DOMMatrix().scaleSelf(scaleX, scaleY),
};
}
startResize(options: OptionResize) {
const {
elements,
handle,
lockRatio,
onResizeStart,
onResizeMove,
onResizeUpdate,
@@ -209,32 +107,19 @@ export class ResizeController {
event,
} = options;
const originals: ReadonlyIBound[] = elements.map(el => ({
const originals: ElementInitialSnapshot[] = elements.map(el => ({
x: el.x,
y: el.y,
w: el.w,
h: el.h,
rotate: el.rotate,
}));
const originalBound: IBound =
originals.length > 1
? getCommonBoundWithRotation(originals)
: {
x: originals[0].x,
y: originals[0].y,
w: originals[0].w,
h: originals[0].h,
rotate: originals[0].rotate,
};
const originalBound = getCommonBoundWithRotation(originals);
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
event.clientX,
event.clientY,
]);
const transform = this.getCoordsTransform(originalBound, handle);
const handleSign = {
x: transform.xSign,
y: transform.ySign,
};
const handleSign = this.getHandleSign(handle);
const onPointerMove = (e: PointerEvent) => {
const currPt = this.gfx.viewport.toModelCoordFromClientCoord([
@@ -245,69 +130,45 @@ export class ResizeController {
dx: currPt[0] - startPt[0],
dy: currPt[1] - startPt[1],
};
const shouldLockRatio =
options.lockRatio || e.shiftKey || elements.length > 1;
const {
scaleX,
scaleY,
handlePos: currentHandlePos,
} = this.getScaleFromDelta(
transform,
delta,
transform.handlePos,
shouldLockRatio
);
const scale = onResizeMove({
scaleX,
scaleY,
originalBound,
const shouldLockRatio = lockRatio || e.shiftKey;
delta = onResizeMove({
dx: delta.dx,
dy: delta.dy,
handleSign,
handlePos: transform.handlePos,
currentHandlePos,
lockRatio: shouldLockRatio,
});
const scaleInfo = this.getScaleMatrix(scale, shouldLockRatio);
if (elements.length === 1) {
this.resizeSingle(
originals[0],
elements[0],
shouldLockRatio,
transform,
scaleInfo,
startPt,
delta,
handleSign,
onResizeUpdate
);
} else {
this.resizeMulti(
originalBound,
originals,
elements,
transform,
scaleInfo,
startPt,
delta,
handleSign,
onResizeUpdate
);
}
};
onResizeStart?.({
handleSign,
handlePos: transform.handlePos,
data: elements.map(model => ({ model })),
});
onResizeStart?.({ data: elements.map(model => ({ model })) });
const onPointerUp = () => {
this.host.removeEventListener('pointermove', onPointerMove);
this.host.removeEventListener('pointerup', onPointerUp);
onResizeEnd?.({
handleSign,
handlePos: transform.handlePos,
data: elements.map(model => ({ model })),
});
onResizeEnd?.({ data: elements.map(model => ({ model })) });
};
this.host.addEventListener('pointermove', onPointerMove);
@@ -315,15 +176,55 @@ export class ResizeController {
}
private resizeSingle(
orig: ReadonlyIBound,
orig: ElementInitialSnapshot,
model: GfxModel,
lockRatio: boolean,
transform: ReturnType<typeof ResizeController.prototype.getCoordsTransform>,
scale: { scaleX: number; scaleY: number; scaleM: DOMMatrix },
startPt: IVec,
delta: {
dx: number;
dy: number;
},
handleSign: { xSign: number; ySign: number },
updateCallback: OptionResize['onResizeUpdate']
) {
const { toLocalM, toLocalRotatedM, toLocal, toModel } = transform;
const { scaleX, scaleY, scaleM } = scale;
const { xSign, ySign } = handleSign;
const pivot = new DOMPoint(
orig.x + (-xSign === 1 ? orig.w : 0),
orig.y + (-ySign === 1 ? orig.h : 0)
);
const toLocalRotatedM = new DOMMatrix()
.translate(-pivot.x, -pivot.y)
.translate(orig.w / 2 + orig.x, orig.h / 2 + orig.y)
.rotate(-orig.rotate)
.translate(-(orig.w / 2 + orig.x), -(orig.h / 2 + orig.y));
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
const toLocal = (p: DOMPoint, withRotation: boolean) =>
p.matrixTransform(withRotation ? toLocalRotatedM : toLocalM);
const toModel = (p: DOMPoint) =>
p.matrixTransform(toLocalRotatedM.inverse());
const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]), true);
const currPtLocal = toLocal(
new DOMPoint(startPt[0] + delta.dx, startPt[1] + delta.dy),
true
);
let scaleX = xSign
? (xSign * (currPtLocal.x - handleLocal.x) + orig.w) / orig.w
: 1;
let scaleY = ySign
? (ySign * (currPtLocal.y - handleLocal.y) + orig.h) / orig.h
: 1;
if (lockRatio) {
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
}
const scaleM = new DOMMatrix().scale(scaleX, scaleY);
const [visualTopLeft, visualBottomRight] = [
new DOMPoint(orig.x, orig.y),
@@ -381,14 +282,45 @@ export class ResizeController {
}
private resizeMulti(
originals: ReadonlyIBound[],
originalBound: Bound,
originals: ElementInitialSnapshot[],
elements: GfxModel[],
transform: ReturnType<ResizeController['getCoordsTransform']>,
scale: { scaleX: number; scaleY: number; scaleM: DOMMatrix },
startPt: IVec,
delta: {
dx: number;
dy: number;
},
handleSign: { xSign: number; ySign: number },
updateCallback: OptionResize['onResizeUpdate']
) {
const { toLocalM } = transform;
const { scaleX, scaleY, scaleM } = scale;
const { xSign, ySign } = handleSign;
const pivot = new DOMPoint(
originalBound.x + ((-xSign + 1) / 2) * originalBound.w,
originalBound.y + ((-ySign + 1) / 2) * originalBound.h
);
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]));
const currPtLocal = toLocal(
new DOMPoint(startPt[0] + delta.dx, startPt[1] + delta.dy)
);
let scaleX = xSign
? (xSign * (currPtLocal.x - handleLocal.x) + originalBound.w) /
originalBound.w
: 1;
let scaleY = ySign
? (ySign * (currPtLocal.y - handleLocal.y) + originalBound.h) /
originalBound.h
: 1;
const min = Math.max(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
const scaleM = new DOMMatrix().scale(scaleX, scaleY);
const data = elements.map((model, i) => {
const orig = originals[i];
@@ -425,7 +357,7 @@ export class ResizeController {
startRotate(option: RotateOption) {
const { event, elements, onRotateUpdate } = option;
const originals: ReadonlyIBound[] = elements.map(el => ({
const originals: ElementInitialSnapshot[] = elements.map(el => ({
x: el.x,
y: el.y,
w: el.w,
@@ -497,7 +429,7 @@ export class ResizeController {
}
private rotateSingle(option: {
orig: ReadonlyIBound;
orig: ElementInitialSnapshot;
model: GfxModel;
startPt: IVec;
currentPt: IVec;
@@ -549,7 +481,7 @@ export class ResizeController {
}
private rotateMulti(option: {
origs: ReadonlyIBound[];
origs: ElementInitialSnapshot[];
models: GfxModel[];
startPt: IVec;
currentPt: IVec;
@@ -635,23 +567,23 @@ export class ResizeController {
private getHandleSign(handle: ResizeHandle) {
switch (handle) {
case 'top-left':
return { x: -1, y: -1 };
return { xSign: -1, ySign: -1 };
case 'top':
return { x: 0, y: -1 };
return { xSign: 0, ySign: -1 };
case 'top-right':
return { x: 1, y: -1 };
return { xSign: 1, ySign: -1 };
case 'right':
return { x: 1, y: 0 };
return { xSign: 1, ySign: 0 };
case 'bottom-right':
return { x: 1, y: 1 };
return { xSign: 1, ySign: 1 };
case 'bottom':
return { x: 0, y: 1 };
return { xSign: 0, ySign: 1 };
case 'bottom-left':
return { x: -1, y: 1 };
return { xSign: -1, ySign: 1 };
case 'left':
return { x: -1, y: 0 };
return { xSign: -1, ySign: 0 };
default:
return { x: 0, y: 0 };
return { xSign: 0, ySign: 0 };
}
}
}
@@ -1,5 +1,3 @@
import type { IBound, IPoint, IVec } from '@blocksuite/global/gfx';
import type { GfxModel } from '../../model/model';
import type { ResizeHandle } from '../resize/manager';
@@ -10,16 +8,6 @@ export type ExtensionElementResizeContext = {
export type ExtensionElementResizeStartContext = {
elements: GfxModel[];
/**
* The position of the handle in the browser coordinate space.
*/
handlePos: IVec;
/**
* The sign (or normal vector) of the handle.
*/
handleSign: IPoint;
handle: ResizeHandle;
};
@@ -28,14 +16,15 @@ export type ExtensionElementResizeEndContext =
export type ExtensionElementResizeMoveContext =
ExtensionElementResizeStartContext & {
scaleX: number;
scaleY: number;
originalBound: IBound;
currentHandlePos: IVec;
dx: number;
dy: number;
lockRatio: boolean;
suggest: (distance: { scaleX: number; scaleY: number }) => void;
handleSign: {
xSign: number;
ySign: number;
};
suggest: (distance: { dx: number; dy: number }) => void;
};
+7 -16
View File
@@ -825,7 +825,7 @@ export class LayerManager extends GfxExtension {
const block = store.getModelById(payload.id);
if (block instanceof GfxBlockElementModel) {
this.delete(block);
this.delete(block as GfxBlockElementModel);
}
}
})
@@ -834,29 +834,20 @@ export class LayerManager extends GfxExtension {
const watchSurface = (surface: SurfaceBlockModel) => {
let lastChildMap = new Map(surface.childMap.peek());
this._disposable.add(
surface.childMap.subscribe(currentChildMap => {
currentChildMap.forEach((_, id) => {
surface.childMap.subscribe(val => {
val.forEach((_, id) => {
if (lastChildMap.has(id)) {
lastChildMap.delete(id);
return;
}
});
lastChildMap.forEach((_, id) => {
const model = this._doc.getModelById(id);
if (model instanceof GfxBlockElementModel) {
this.delete(model);
const block = this._doc.getBlock(id);
if (block?.model) {
this.delete(block.model as GfxBlockElementModel);
}
});
currentChildMap.forEach((_, id) => {
const model = store.getModelById(id);
if (
model instanceof GfxBlockElementModel &&
!this.blocks.includes(model)
) {
this.add(model);
}
});
lastChildMap = new Map(currentChildMap);
lastChildMap = new Map(val);
})
);
@@ -19,5 +19,3 @@ export type BlockSysProps = {
children?: BlockModel[];
};
export type BlockProps = BlockSysProps & Record<string, unknown>;
export type PropsOfModel<T> = T extends BlockModel<infer P> ? P : never;
@@ -22,8 +22,7 @@ import {
type BlockModel,
type BlockOptions,
type BlockProps,
type BlockSysProps,
type PropsOfModel,
toDraftModel,
type YBlock,
} from '../block/index.js';
import { DocCRUD } from './crud.js';
@@ -854,12 +853,9 @@ export class Store {
*
* @category Block CRUD
*/
updateBlock<T extends BlockModel = BlockModel>(
modelOrId: T | string,
callBackOrProps:
| (() => void)
| Partial<(PropsOfModel<T> & BlockSysProps) | BlockProps>
updateBlock(
modelOrId: BlockModel | string,
callBackOrProps: (() => void) | Partial<BlockProps>
) {
if (this.readonly) {
console.error('cannot modify data in readonly mode');
@@ -1148,13 +1144,82 @@ export class Store {
return;
}
this.transact(() => {
this._crud.moveBlocks(
blocksToMove.map(model => model.id),
newParent.id,
targetSibling?.id ?? null,
shouldInsertBeforeSibling
if (
blocksToMove.length > 1 &&
targetSibling &&
blocksToMove.includes(targetSibling)
) {
console.error(
'Cannot move blocks when the target sibling is in the blocks to move'
);
return;
}
if (blocksToMove.length === 1 && targetSibling === blocksToMove[0]) {
return;
}
if (blocksToMove.includes(newParent)) {
console.error(
'Cannot move blocks when the new parent is in the blocks to move'
);
return;
}
for (let i = 0; i < blocksToMove.length - 1; i++) {
const block = blocksToMove[i];
const nextBlock = blocksToMove[i + 1];
if (
block.parent &&
block.parent === nextBlock.parent &&
this.getNext(block) !== nextBlock
) {
console.error(
'The blocks to move are not contiguous under their parent'
);
return;
}
}
this.transact(() => {
let insertIndex = 0;
const updateInsertIndex = () => {
if (targetSibling) {
const targetIndex = newParent.children.indexOf(targetSibling);
if (targetIndex === -1) {
console.warn('Target sibling not found, just insert to the end');
} else {
insertIndex = shouldInsertBeforeSibling
? targetIndex
: targetIndex + 1;
}
}
};
blocksToMove.forEach(block => {
const draftModel = toDraftModel(block);
const props = {
...draftModel.props,
children: block.children,
};
console.log(props);
this._crud.deleteBlock(block.id, {
deleteChildren: false,
});
updateInsertIndex();
this._crud.addBlock(
draftModel.id,
draftModel.flavour,
props,
newParent.id,
insertIndex
);
});
});
}
+12 -16
View File
@@ -132,13 +132,12 @@ export class DocEngine {
this.logger
);
const subscriber = state.mainPeer.onStatusChange.subscribe(() => {
if (!signal.aborted)
this.updateSyncingState(state.mainPeer, state.shadowPeers);
});
cleanUp.push(() => {
subscriber.unsubscribe();
});
cleanUp.push(
state.mainPeer.onStatusChange.subscribe(() => {
if (!signal.aborted)
this.updateSyncingState(state.mainPeer, state.shadowPeers);
}).unsubscribe
);
this.updateSyncingState(state.mainPeer, state.shadowPeers);
@@ -153,15 +152,12 @@ export class DocEngine {
this.priorityTarget,
this.logger
);
const subscriber = peer.onStatusChange.subscribe(() => {
if (!signal.aborted)
this.updateSyncingState(state.mainPeer, state.shadowPeers);
});
cleanUp.push(() => {
subscriber.unsubscribe();
});
cleanUp.push(
peer.onStatusChange.subscribe(() => {
if (!signal.aborted)
this.updateSyncingState(state.mainPeer, state.shadowPeers);
}).unsubscribe
);
return peer;
});
@@ -31,15 +31,12 @@ describe('frame', () => {
);
await wait();
const getFrameTitle = (frameId: string) => {
const frameTitleWidget = service.std.view.getWidget(
'affine-frame-title-widget',
frameId
) as AffineFrameTitleWidget | null;
return frameTitleWidget?.shadowRoot?.querySelector('affine-frame-title');
};
const frameTitleWidget = service.std.view.getWidget(
'affine-frame-title-widget',
doc.root!.id
) as AffineFrameTitleWidget | null;
const frameTitle = getFrameTitle(frame);
const frameTitle = frameTitleWidget?.getFrameTitle(frame);
const rect = frameTitle?.getBoundingClientRect();
expect(frameTitle).toBeTruthy();
@@ -61,7 +58,7 @@ describe('frame', () => {
);
await wait();
const nestedTitle = getFrameTitle(nestedFrame);
const nestedTitle = frameTitleWidget?.getFrameTitle(nestedFrame);
expect(nestedTitle).toBeTruthy();
if (!nestedTitle) return;
@@ -30,7 +30,7 @@ describe('Shape rendering with DOM renderer', () => {
fill: '#ff0000',
stroke: '#000000',
};
const shapeId = surfaceModel.addElement(shapeProps);
const shapeId = surfaceModel.addElement(shapeProps as any);
await new Promise(resolve => setTimeout(resolve, 100));
const shapeElement = surfaceView?.renderRoot.querySelector(
@@ -73,7 +73,7 @@ describe('Shape rendering with DOM renderer', () => {
subType: 'ellipse',
xywh: '[200, 200, 50, 50]',
};
const shapeId = surfaceModel.addElement(shapeProps);
const shapeId = surfaceModel.addElement(shapeProps as any);
await new Promise(resolve => setTimeout(resolve, 100));
@@ -91,48 +91,4 @@ describe('Shape rendering with DOM renderer', () => {
);
expect(shapeElement).toBeNull();
});
test('should correctly render diamond shape', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const shapeProps = {
type: 'shape',
subType: 'diamond',
xywh: '[150, 150, 80, 60]',
fillColor: '#ff0000',
strokeColor: '#000000',
filled: true,
};
const shapeId = surfaceModel.addElement(shapeProps);
await wait(100);
const shapeElement = surfaceView?.renderRoot.querySelector<HTMLElement>(
`[data-element-id="${shapeId}"]`
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.width).toBe('80px');
expect(shapeElement?.style.height).toBe('60px');
});
test('should correctly render triangle shape', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const shapeProps = {
type: 'shape',
subType: 'triangle',
xywh: '[150, 150, 80, 60]',
fillColor: '#ff0000',
strokeColor: '#000000',
filled: true,
};
const shapeId = surfaceModel.addElement(shapeProps);
await wait(100);
const shapeElement = surfaceView?.renderRoot.querySelector<HTMLElement>(
`[data-element-id="${shapeId}"]`
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.width).toBe('80px');
expect(shapeElement?.style.height).toBe('60px');
});
});
@@ -40,7 +40,6 @@ describe('basic', () => {
xywh: '[100, 0, 100, 100]',
index: service.generateIndex(),
})!;
await wait(0); // wait next frame
frameId = service.crud.addBlock(
'affine:frame',
{
@@ -7,40 +7,30 @@ import type { InitFn } from './utils.js';
const presetMarkdown = `Click the 🔁 button to switch between editors dynamically - they are fully compatible!`;
export const preset: InitFn = async (collection: Workspace, id: string) => {
let doc = collection.getDoc(id);
const hasDoc = !!doc;
if (!doc) {
doc = collection.createDoc(id);
}
const doc = collection.createDoc(id).getStore({ id });
doc.load();
// Add root block and surface block at root level
const rootId = doc.addBlock('affine:page', {
title: new Text('BlockSuite Playground'),
});
doc.addBlock('affine:surface', {}, rootId);
const store = doc.getStore({ id });
store.load();
// Add note block inside root block
const noteId = doc.addBlock(
'affine:note',
{ xywh: '[0, 100, 800, 640]' },
rootId
);
// Run only once on all clients.
let noteId: string;
if (!hasDoc) {
// Add root block and surface block at root level
const rootId = store.addBlock('affine:page', {
title: new Text('BlockSuite Playground'),
});
store.addBlock('affine:surface', {}, rootId);
// Import preset markdown content inside note block
await MarkdownTransformer.importMarkdownToBlock({
doc,
blockId: noteId,
markdown: presetMarkdown,
extensions: getTestStoreManager().get('store'),
});
// Add note block inside root block
noteId = store.addBlock(
'affine:note',
{ xywh: '[0, 100, 800, 640]' },
rootId
);
// Import preset markdown content inside note block
await MarkdownTransformer.importMarkdownToBlock({
doc: store,
blockId: noteId,
markdown: presetMarkdown,
extensions: getTestStoreManager().get('store'),
});
}
store.resetHistory();
doc.resetHistory();
};
preset.id = 'preset';
-1
View File
@@ -24,7 +24,6 @@
"@types/katex": "^0.16.7",
"browser-fs-access": "^0.37.0",
"jszip": "^3.10.1",
"katex": "^0.16.11",
"lit": "^3.2.0",
"lz-string": "^1.5.0",
"rxjs": "^7.8.1",
-2
View File
@@ -1,8 +1,6 @@
@import '@toeverything/theme/style.css';
@import '@toeverything/theme/fonts.css';
@import 'katex/dist/katex.min.css';
@font-face {
font-family: 'color-emoji';
src:
+1 -11
View File
@@ -8,11 +8,6 @@ export const AFFINE_PRO_LICENSE_AES_KEY: string | undefined | null
export const AFFINE_PRO_PUBLIC_KEY: string | undefined | null
export interface Chunk {
index: number
content: string
}
export declare function fromModelName(modelName: string): Tokenizer | null
export declare function getMime(input: Uint8Array): string
@@ -27,11 +22,6 @@ export declare function mergeUpdatesInApplyWay(updates: Array<Buffer>): Buffer
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
export interface ParsedDoc {
name: string
chunks: Array<Chunk>
}
export declare function parseDoc(filePath: string, doc: Buffer): Promise<ParsedDoc>
export declare function parseDoc(filePath: string, doc: Buffer): Promise<{ name: string, chunks: Array<{index: number, content: string}> }>
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
+1 -1
View File
@@ -32,7 +32,7 @@
"build:debug": "napi build"
},
"devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.81",
"@napi-rs/cli": "3.0.0-alpha.78",
"lib0": "^0.2.99",
"tiktoken": "^1.0.17",
"tinybench": "^4.0.0",
+25 -34
View File
@@ -2,21 +2,9 @@ use affine_common::doc_loader::Doc;
use napi::{
anyhow::anyhow,
bindgen_prelude::{AsyncTask, Buffer},
Env, Result, Task,
Env, JsObject, Result, Task,
};
#[napi(object)]
pub struct Chunk {
pub index: i64,
pub content: String,
}
#[napi(object)]
pub struct ParsedDoc {
pub name: String,
pub chunks: Vec<Chunk>,
}
pub struct Document {
inner: Doc,
}
@@ -26,20 +14,24 @@ impl Document {
self.inner.name.clone()
}
fn chunks(&self) -> Vec<Chunk> {
self
.inner
.chunks
.iter()
.enumerate()
.map(|(i, chunk)| {
let content = crate::utils::clean_content(&chunk.content);
Chunk {
index: i as i64,
content,
}
})
.collect::<Vec<Chunk>>()
fn chunks(&self, env: Env) -> Result<JsObject> {
let mut array = env.create_array_with_length(self.inner.chunks.len())?;
for (i, chunk) in self.inner.chunks.iter().enumerate() {
let content = crate::utils::clean_content(&chunk.content);
let mut obj = env.create_object()?;
obj.set_named_property("index", i as i64)?;
obj.set_named_property("content", content)?;
array.set_element(i as u32, obj)?;
}
Ok(array)
}
fn resolve(self, env: Env) -> Result<JsObject> {
let mut obj = env.create_object()?;
obj.set_named_property("name", self.name())?;
obj.set_named_property("chunks", self.chunks(env)?)?;
Ok(obj)
}
}
@@ -51,22 +43,21 @@ pub struct AsyncParseDocResponse {
#[napi]
impl Task for AsyncParseDocResponse {
type Output = Document;
type JsValue = ParsedDoc;
type JsValue = JsObject;
fn compute(&mut self) -> Result<Self::Output> {
let doc = Doc::new(&self.file_path, &self.doc).map_err(|e| anyhow!(e))?;
Ok(Document { inner: doc })
}
fn resolve(&mut self, _: Env, doc: Document) -> Result<Self::JsValue> {
Ok(ParsedDoc {
name: doc.name(),
chunks: doc.chunks(),
})
fn resolve(&mut self, env: Env, doc: Document) -> Result<Self::JsValue> {
doc.resolve(env)
}
}
#[napi]
#[napi(
ts_return_type = "Promise<{ name: string, chunks: Array<{index: number, content: string}> }>"
)]
pub fn parse_doc(file_path: String, doc: Buffer) -> AsyncTask<AsyncParseDocResponse> {
AsyncTask::new(AsyncParseDocResponse {
file_path,
+7 -7
View File
@@ -1,7 +1,7 @@
use std::convert::TryFrom;
use affine_common::hashcash::Stamp;
use napi::{bindgen_prelude::AsyncTask, Env, Result as NapiResult, Task};
use napi::{bindgen_prelude::AsyncTask, Env, JsBoolean, JsString, Result as NapiResult, Task};
use napi_derive::napi;
pub struct AsyncVerifyChallengeResponse {
@@ -13,7 +13,7 @@ pub struct AsyncVerifyChallengeResponse {
#[napi]
impl Task for AsyncVerifyChallengeResponse {
type Output = bool;
type JsValue = bool;
type JsValue = JsBoolean;
fn compute(&mut self) -> NapiResult<Self::Output> {
Ok(if let Ok(stamp) = Stamp::try_from(self.response.as_str()) {
@@ -23,8 +23,8 @@ impl Task for AsyncVerifyChallengeResponse {
})
}
fn resolve(&mut self, _: Env, output: bool) -> NapiResult<Self::JsValue> {
Ok(output)
fn resolve(&mut self, env: Env, output: bool) -> NapiResult<Self::JsValue> {
env.get_boolean(output)
}
}
@@ -49,14 +49,14 @@ pub struct AsyncMintChallengeResponse {
#[napi]
impl Task for AsyncMintChallengeResponse {
type Output = String;
type JsValue = String;
type JsValue = JsString;
fn compute(&mut self) -> NapiResult<Self::Output> {
Ok(Stamp::mint(self.resource.clone(), self.bits).format())
}
fn resolve(&mut self, _: Env, output: String) -> NapiResult<Self::JsValue> {
Ok(output)
fn resolve(&mut self, env: Env, output: String) -> NapiResult<Self::JsValue> {
env.create_string(&output)
}
}
+11 -20
View File
@@ -15,25 +15,16 @@ pub fn from_model_name(model_name: String) -> Option<Tokenizer> {
impl Tokenizer {
#[napi]
pub fn count(&self, content: String, allowed_special: Option<Vec<String>>) -> u32 {
let allowed_special = if let Some(allowed_special) = &allowed_special {
HashSet::from_iter(allowed_special.iter().map(|s| s.as_str()))
} else {
Default::default()
};
self.inner.encode(&content, &allowed_special).0.len() as u32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokenizer() {
let tokenizer = from_model_name("gpt-4.1".to_string()).unwrap();
let content = "Hello, world!";
let count = tokenizer.count(content.to_string(), None);
assert!(count > 0);
self
.inner
.encode(
&content,
if let Some(allowed_special) = &allowed_special {
HashSet::from_iter(allowed_special.iter().map(|s| s.as_str()))
} else {
Default::default()
},
)
.len() as u32
}
}
@@ -1,9 +1,2 @@
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'ai_workspace_files') THEN
-- AlterTable
ALTER TABLE "ai_workspace_files"
ADD COLUMN "blob_id" VARCHAR NOT NULL DEFAULT '';
END IF;
END
$$;
-- AlterTable
ALTER TABLE "ai_workspace_files" ADD COLUMN "blob_id" VARCHAR NOT NULL DEFAULT '';
@@ -5,25 +5,16 @@
- The primary key for the `ai_workspace_file_embeddings` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'ai_workspace_embeddings') AND
EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'ai_workspace_file_embeddings') THEN
-- DropIndex
DROP INDEX "ai_workspace_embeddings_workspace_id_doc_id_chunk_key";
-- DropIndex
DROP INDEX "ai_workspace_embeddings_workspace_id_doc_id_chunk_key";
-- DropIndex
DROP INDEX "ai_workspace_file_embeddings_workspace_id_file_id_chunk_key";
-- DropIndex
DROP INDEX "ai_workspace_file_embeddings_workspace_id_file_id_chunk_key";
-- AlterTable
ALTER TABLE "ai_workspace_embeddings"
DROP CONSTRAINT "ai_workspace_embeddings_pkey",
ADD CONSTRAINT "ai_workspace_embeddings_pkey" PRIMARY KEY ("workspace_id", "doc_id", "chunk");
-- AlterTable
ALTER TABLE "ai_workspace_embeddings" DROP CONSTRAINT "ai_workspace_embeddings_pkey",
ADD CONSTRAINT "ai_workspace_embeddings_pkey" PRIMARY KEY ("workspace_id", "doc_id", "chunk");
-- AlterTable
ALTER TABLE "ai_workspace_file_embeddings"
DROP CONSTRAINT "ai_workspace_file_embeddings_pkey",
ADD CONSTRAINT "ai_workspace_file_embeddings_pkey" PRIMARY KEY ("workspace_id", "file_id", "chunk");
END IF;
END
$$;
-- AlterTable
ALTER TABLE "ai_workspace_file_embeddings" DROP CONSTRAINT "ai_workspace_file_embeddings_pkey",
ADD CONSTRAINT "ai_workspace_file_embeddings_pkey" PRIMARY KEY ("workspace_id", "file_id", "chunk");
+5 -5
View File
@@ -28,11 +28,11 @@
"dependencies": {
"@affine/reader": "workspace:*",
"@affine/server-native": "workspace:*",
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/anthropic": "^1.2.10",
"@ai-sdk/google": "^1.2.18",
"@ai-sdk/google-vertex": "^2.2.23",
"@ai-sdk/openai": "^1.3.22",
"@ai-sdk/perplexity": "^1.1.9",
"@ai-sdk/google-vertex": "^2.2.22",
"@ai-sdk/openai": "^1.3.21",
"@ai-sdk/perplexity": "^1.1.6",
"@apollo/server": "^4.11.3",
"@aws-sdk/client-s3": "^3.779.0",
"@aws-sdk/s3-request-presigner": "^3.779.0",
@@ -100,7 +100,7 @@
"nanoid": "^5.0.9",
"nest-commander": "^3.15.0",
"nest-winston": "^1.9.7",
"nestjs-cls": "^6.0.0",
"nestjs-cls": "^5.0.0",
"nodemailer": "^7.0.0",
"on-headers": "^1.0.2",
"piscina": "^5.0.0-alpha.0",
@@ -47,36 +47,5 @@ function runPredeployScript() {
});
}
function fixFailedMigrations() {
console.log('fixing failed migrations.');
const maybeFailedMigrations = [
'20250521083048_fix_workspace_embedding_chunk_primary_key',
];
for (const migration of maybeFailedMigrations) {
try {
execSync(`yarn prisma migrate resolve --rolled-back ${migration}`, {
encoding: 'utf-8',
env: process.env,
stdio: 'pipe',
});
console.log(`migration [${migration}] has been rolled back.`);
} catch (err) {
if (
err.message.includes(
'cannot be rolled back because it is not in a failed state'
) ||
err.message.includes(
'cannot be rolled back because it was never applied'
)
) {
// migration has been rolled back, skip it
continue;
}
throw err;
}
}
}
prepare();
fixFailedMigrations();
runPredeployScript();
@@ -272,68 +272,13 @@ test('should validate markdown list', t => {
// ==================== action ====================
const actions = [
{
name: 'Should chat with histories',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content: `
Hi! Im going to send you a technical term related to real-time collaborative editing (e.g., CRDT, Operational Transformation, OT Composer, etc.). Whenever I send you a term:
1. Translate it into Chinese (send me the Chinese version).
2. Then translate that Chinese back into English (send me the retranslated English).
3. Provide a brief, English-language introduction and context for this concept.
4. In that English explanation, annotate any niche terms with their Chinese equivalents in parentheses (for example: Conflict-Free Replicated Data Type ()).
5. Finally, give the origin or term history (e.g., who introduced it, in which paper or year).
If you understand, please proceed by explaining the term CRDT.
`.trim(),
},
{
role: 'assistant' as const,
content: `
1. **Chinese Translation:**
CRDT ****
2. **Back-Translation to English:**
**Conflict-Free Replicated Data Type**
3. **English Introduction & Context:**
A **Conflict-Free Replicated Data Type ()** is an abstract data type designed for distributed systems where replicas of shared state may be modified concurrently without requiring coordination. CRDTs allow multiple users or processes to update the same data structure (for example, a shared document in a collaborative editor) at the same time.
- **Key Terms (with Chinese equivalents):**
- **Replica ():** Each node or client maintains its own copy of the data.
- **State-based () vs. Operation-based ():** Two main CRDT classes; state-based CRDTs exchange entire state snapshots occasionally, whereas operation-based CRDTs broadcast only incremental operations.
- **Merge Function ():** A deterministic function that resolves differences between two replicas without conflicts.
CRDTs enable **eventual consistency ()** in real-time collaborative editors by ensuring that, after all updates propagate, every replica converges to the same state, even if operations arrive in different orders. This approach removes the need for a centralized server to resolve conflicts, making offline or peer-to-peer editing possible.
4. **Origin / Term History:**
The term **CRDT** was first introduced by Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski in their 2011 paper titled Conflict-free Replicated Data Types (published in the _Stabilization, Safety, and Security of Distributed Systems (SSS)_ conference). They formalized two families of CRDTsstate-based (Convergent Replicated Data Types or CvRDTs) and operation-based (Commutative Replicated Data Types or CmRDTs)and proved their convergence properties under asynchronous, unreliable networks.
`.trim(),
},
{
role: 'user' as const,
content: `Thanks! Now please just tell me the **Chinese translation** and the **back-translated English term** that you provided previously for “CRDT.” Do not reprint the full introduction—only those two lines.`,
},
],
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
const lower = result.toLowerCase();
t.assert(
lower.includes('无冲突复制数据类型') &&
lower.includes('conflict-free replicated data type'),
'The response should include “无冲突复制数据类型” and “Conflict-Free Replicated Data Type”'
);
},
type: 'text' as const,
},
{
name: 'Should not have citation',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content: 'what is AFFiNE AI?',
content: 'what is ssot',
params: {
files: [
{
@@ -79,7 +79,7 @@ export class MockCopilotProvider extends OpenAIProvider {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Structured],
output: [ModelOutputType.Text],
},
],
},
@@ -48,7 +48,7 @@ export class JobQueue {
const removed = await queue.remove(jobId);
if (removed) {
this.logger.log(`Job ${jobName}(id=${jobId}) removed from queue ${ns}`);
this.logger.log(`Job ${jobName} removed from queue ${ns}`);
return job.data.payload;
}
@@ -1,135 +0,0 @@
# Snapshot report for `src/base/utils/__tests__/duration.spec.ts`
The actual snapshot is saved in `duration.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should parse duration strings correctly
> parser - 1ms
'{"ms":1}'
> ms - 1ms
1
> parser - 1s
'{"s":1}'
> ms - 1s
1000
> parser - 1m
'{"m":1}'
> ms - 1m
60000
> parser - 1h
'{"h":1}'
> ms - 1h
3600000
> parser - 1d
'{"d":1}'
> ms - 1d
86400000
> parser - 1w
'{"w":1}'
> ms - 1w
604800000
> parser - 1M
'{"M":1}'
> ms - 1M
2592000000
> parser - 1y
'{"y":1}'
> ms - 1y
31536000000
> parser - 1000ms
'{"ms":1000}'
> ms - 1000ms
1000
> parser - 60s
'{"s":60}'
> ms - 60s
60000
> parser - 30m
'{"m":30}'
> ms - 30m
1800000
> parser - 1h30m
'{"h":1,"m":30}'
> ms - 1h30m
5400000
> parser - 15d
'{"d":15}'
> ms - 15d
1296000000
> parser - 1y
'{"y":1}'
> ms - 1y
31536000000
> parser - 12M
'{"M":12}'
> ms - 12M
31104000000
> parser - 1y1M1d1h1m1s1ms
'{"y":1,"M":1,"d":1,"h":1,"m":1,"s":1,"ms":1}'
> ms - 1y1M1d1h1m1s1ms
34218061001
@@ -1,37 +0,0 @@
import test from 'ava';
import { Due } from '../duration';
test('should parse duration strings correctly', t => {
const testcases = [
'1ms',
'1s',
'1m',
'1h',
'1d',
'1w',
'1M',
'1y',
'1000ms',
'60s',
'30m',
'1h30m',
'15d',
'1y',
'12M',
'1y1M1d1h1m1s1ms',
];
for (const str of testcases) {
t.snapshot(JSON.stringify(Due.parse(str)), `parser - ${str}`);
t.snapshot(Due.ms(str), `ms - ${str}`);
}
});
test('should calc relative time correctly', t => {
const date = new Date();
t.is(Due.before('1d', date).getTime(), date.getTime() - 1000 * 60 * 60 * 24);
const date2 = new Date();
t.is(Due.after('1d', date2).getTime(), date2.getTime() + 1000 * 60 * 60 * 24);
});
@@ -1,78 +0,0 @@
type DurationUnit = 'd' | 'w' | 'M' | 'y' | 'h' | 'm' | 's' | 'ms';
type DurationInput = Partial<Record<DurationUnit, number>>;
const UnitToSecMap: Record<DurationUnit, number> = {
ms: 0.001,
s: 1,
m: 60,
h: 3600,
d: 24 * 3600,
w: 7 * 24 * 3600,
M: 30 * 24 * 3600,
y: 365 * 24 * 3600,
};
const KnownCharCodeToCharMap: Record<number, DurationUnit> = {
100: 'd',
119: 'w',
77: 'M',
121: 'y',
104: 'h',
109: 'm',
115: 's',
};
function parse(str: string): DurationInput {
let input: DurationInput = {};
let acc = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
const code = ch.charCodeAt(0);
// number [0..9]
if (code >= 48 && code <= 57) {
acc = acc * 10 + code - 48;
} else {
let unit = KnownCharCodeToCharMap[code];
if (!unit) {
throw new Error(`Invalid duration string unit ${ch}`);
}
// look ahead a char for 'ms' checking if unit met 'm'
if (unit === 'm' && str[i + 1] === 's') {
unit = 'ms';
i++;
}
input[unit] = acc;
acc = 0;
}
}
return input;
}
export const Due = {
ms: (dueStr: string | DurationInput) => {
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
return Object.entries(input).reduce((duration, [unit, val]) => {
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0) * 1000;
}, 0);
},
s: (dueStr: string | DurationInput) => {
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
return Object.entries(input).reduce((duration, [unit, val]) => {
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0);
}, 0);
},
parse,
after: (dueStr: string | number | DurationInput, date?: Date) => {
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
return new Date((date?.getTime() ?? Date.now()) + timestamp);
},
before: (dueStr: string | number | DurationInput, date?: Date) => {
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
return new Date((date?.getTime() ?? Date.now()) - timestamp);
},
};
@@ -1,4 +1,3 @@
export * from './duration';
export * from './promise';
export * from './request';
export * from './stream';
@@ -203,9 +203,7 @@ export class SpaceSyncGateway
await client.join(room);
}
} else {
if (spaceType === SpaceType.Workspace) {
this.event.emit('workspace.embedding', { workspaceId: spaceId });
}
this.event.emit('workspace.embedding', { workspaceId: spaceId });
await this.selectAdapter(client, spaceType).join(user.id, spaceId);
}
@@ -1,75 +1,41 @@
import { Logger } from '@nestjs/common';
import {
createOpenAI,
type OpenAIProvider as VercelOpenAIProvider,
} from '@ai-sdk/openai';
import { embedMany, generateObject } from 'ai';
import { chunk } from 'lodash-es';
import {
CopilotPromptNotFound,
CopilotProviderNotSupported,
} from '../../../base';
import type { ChunkSimilarity, Embedding } from '../../../models';
import type { PromptService } from '../prompt';
import {
type CopilotProvider,
type CopilotProviderFactory,
type ModelFullConditions,
ModelInputType,
ModelOutputType,
} from '../providers';
import { ChunkSimilarity, Embedding } from '../../../models';
import { OpenAIConfig } from '../providers/openai';
import {
EMBEDDING_DIMENSIONS,
EmbeddingClient,
getReRankSchema,
type ReRankResult,
ReRankResult,
} from './types';
const RERANK_PROMPT = 'Rerank results';
const RERANK_MODEL = 'gpt-4.1-mini';
export class ProductionEmbeddingClient extends EmbeddingClient {
private readonly logger = new Logger(ProductionEmbeddingClient.name);
export class OpenAIEmbeddingClient extends EmbeddingClient {
readonly #instance: VercelOpenAIProvider;
constructor(
private readonly providerFactory: CopilotProviderFactory,
private readonly prompt: PromptService
) {
constructor(config: OpenAIConfig) {
super();
}
override async configured(): Promise<boolean> {
const embedding = await this.providerFactory.getProvider({
outputType: ModelOutputType.Embedding,
this.#instance = createOpenAI({
apiKey: config.apiKey,
baseURL: config.baseUrl,
});
const result = Boolean(embedding);
if (!result) {
this.logger.warn(
'Copilot embedding client is not configured properly, please check your configuration.'
);
}
return result;
}
private async getProvider(
cond: ModelFullConditions
): Promise<CopilotProvider> {
const provider = await this.providerFactory.getProvider(cond);
if (!provider) {
throw new CopilotProviderNotSupported({
provider: 'embedding',
kind: cond.outputType || 'embedding',
});
}
return provider;
}
async getEmbeddings(input: string[]): Promise<Embedding[]> {
const provider = await this.getProvider({
outputType: ModelOutputType.Embedding,
const modelInstance = this.#instance.embedding('text-embedding-3-large', {
dimensions: EMBEDDING_DIMENSIONS,
});
this.logger.verbose(`Using provider ${provider.type} for embedding`, input);
const embeddings = await provider.embedding(
{ inputTypes: [ModelInputType.Text] },
input,
{ dimensions: EMBEDDING_DIMENSIONS }
);
const { embeddings } = await embedMany({
model: modelInstance,
values: input,
});
return Array.from(embeddings.entries()).map(([index, embedding]) => ({
index,
@@ -78,6 +44,27 @@ export class ProductionEmbeddingClient extends EmbeddingClient {
}));
}
private getRelevancePrompt<Chunk extends ChunkSimilarity = ChunkSimilarity>(
query: string,
embeddings: Chunk[]
) {
const results = embeddings
.map(e => {
const targetId = 'docId' in e ? e.docId : 'fileId' in e ? e.fileId : '';
// NOTE: not xml, just for the sake of the prompt format
return [
'<result>',
`<targetId>${targetId}</targetId>`,
`<chunk>${e.chunk}</chunk>`,
`<content>${e.content}</content>`,
'</result>',
];
})
.flat()
.join('\n');
return `Generate a score array based on the search results list to measure the likelihood that the information contained in the search results is useful for the report on the following topic: ${query}\n\nHere are the search results:\n<results>\n${results}\n</results>`;
}
private async getEmbeddingRelevance<
Chunk extends ChunkSimilarity = ChunkSimilarity,
>(
@@ -85,36 +72,19 @@ export class ProductionEmbeddingClient extends EmbeddingClient {
embeddings: Chunk[],
signal?: AbortSignal
): Promise<ReRankResult> {
if (!embeddings.length) return [];
const prompt = this.getRelevancePrompt(query, embeddings);
const modelInstance = this.#instance(RERANK_MODEL);
const prompt = await this.prompt.get(RERANK_PROMPT);
if (!prompt) {
throw new CopilotPromptNotFound({ name: RERANK_PROMPT });
}
const provider = await this.getProvider({ modelId: prompt.model });
const schema = getReRankSchema(embeddings.length);
const ranks = await provider.structure(
{ modelId: prompt.model },
prompt.finish({
query,
results: embeddings.map(e => {
const targetId =
'docId' in e ? e.docId : 'fileId' in e ? e.fileId : '';
return { targetId, chunk: e.chunk, content: e.content };
}),
schema,
}),
{ maxRetries: 3, signal }
);
try {
return schema.parse(JSON.parse(ranks)).ranks;
} catch (error) {
this.logger.error('Failed to parse rerank results', error);
// silent error, will fallback to default sorting in parent method
return [];
}
const {
object: { ranks },
} = await generateObject({
model: modelInstance,
prompt,
schema: getReRankSchema(embeddings.length),
maxRetries: 3,
abortSignal: signal,
});
return ranks;
}
override async reRank<Chunk extends ChunkSimilarity = ChunkSimilarity>(
@@ -140,10 +110,6 @@ export class ProductionEmbeddingClient extends EmbeddingClient {
const ranks = [];
for (const c of chunk(sortedEmbeddings, Math.min(topK, 10))) {
const rank = await this.getEmbeddingRelevance(query, c, signal);
if (c.length !== rank.length) {
// llm return wrong result, fallback to default sorting
return super.reRank(query, embeddings, topK, signal);
}
ranks.push(rank);
}
@@ -158,21 +124,6 @@ export class ProductionEmbeddingClient extends EmbeddingClient {
}
}
let EMBEDDING_CLIENT: EmbeddingClient | undefined;
export async function getEmbeddingClient(
providerFactory: CopilotProviderFactory,
prompt: PromptService
): Promise<EmbeddingClient | undefined> {
if (EMBEDDING_CLIENT) {
return EMBEDDING_CLIENT;
}
const client = new ProductionEmbeddingClient(providerFactory, prompt);
if (await client.configured()) {
EMBEDDING_CLIENT = client;
}
return EMBEDDING_CLIENT;
}
export class MockEmbeddingClient extends EmbeddingClient {
async getEmbeddings(input: string[]): Promise<Embedding[]> {
return input.map((_, i) => ({
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import {
AFFiNELogger,
BlobNotFound,
CallMetric,
Config,
CopilotContextFileNotSupported,
DocNotFound,
EventBus,
@@ -14,11 +14,9 @@ import {
} from '../../../base';
import { DocReader } from '../../../core/doc';
import { Models } from '../../../models';
import { PromptService } from '../prompt';
import { CopilotProviderFactory } from '../providers';
import { CopilotStorage } from '../storage';
import { readStream } from '../utils';
import { getEmbeddingClient } from './embedding';
import { OpenAIEmbeddingClient } from './embedding';
import type { Chunk, DocFragment } from './types';
import { EMBEDDING_DIMENSIONS, EmbeddingClient } from './types';
@@ -31,12 +29,11 @@ export class CopilotContextDocJob {
private client: EmbeddingClient | undefined;
constructor(
private readonly config: Config,
private readonly doc: DocReader,
private readonly event: EventBus,
private readonly logger: AFFiNELogger,
private readonly models: Models,
private readonly providerFactory: CopilotProviderFactory,
private readonly prompt: PromptService,
private readonly queue: JobQueue,
private readonly storage: CopilotStorage
) {
@@ -56,8 +53,10 @@ export class CopilotContextDocJob {
private async setup() {
this.supportEmbedding =
await this.models.copilotContext.checkEmbeddingAvailable();
if (this.supportEmbedding) {
this.client = await getEmbeddingClient(this.providerFactory, this.prompt);
if (this.supportEmbedding && this.config.copilot.providers.openai.apiKey) {
this.client = new OpenAIEmbeddingClient(
this.config.copilot.providers.openai
);
}
}
@@ -66,7 +65,6 @@ export class CopilotContextDocJob {
return this.client as EmbeddingClient;
}
@CallMetric('ai', 'addFileEmbeddingQueue')
async addFileEmbeddingQueue(file: Jobs['copilot.embedding.files']) {
if (!this.supportEmbedding) return;
@@ -89,14 +87,6 @@ export class CopilotContextDocJob {
if (!this.supportEmbedding) return;
for (const { workspaceId, docId } of docs) {
const jobId = `workspace:embedding:${workspaceId}:${docId}`;
const job = await this.queue.get(jobId, 'copilot.embedding.docs');
// if the job exists and is older than 5 minute, remove it
if (job && job.timestamp + 5 * 60 * 1000 < Date.now()) {
this.logger.verbose(`Removing old embedding job ${jobId}`);
await this.queue.remove(jobId, 'copilot.embedding.docs');
}
await this.queue.add(
'copilot.embedding.docs',
{
@@ -107,7 +97,6 @@ export class CopilotContextDocJob {
{
jobId: `workspace:embedding:${workspaceId}:${docId}`,
priority: options?.priority ?? 1,
timestamp: Date.now(),
}
);
}
@@ -140,10 +129,6 @@ export class CopilotContextDocJob {
if (enableDocEmbedding) {
const toBeEmbedDocIds =
await this.models.copilotWorkspace.findDocsToEmbed(workspaceId);
this.logger.debug('Trigger embedding for docs', {
workspaceId,
toBeEmbedDocs: toBeEmbedDocIds.length,
});
for (const docId of toBeEmbedDocIds) {
await this.queue.add(
'copilot.embedding.docs',
@@ -195,7 +180,7 @@ export class CopilotContextDocJob {
);
}
private async readCopilotBlob(
async readCopilotBlob(
userId: string,
workspaceId: string,
blobId: string,
@@ -345,9 +330,6 @@ export class CopilotContextDocJob {
workspaceId,
docId
);
this.logger.verbose(
`Check if doc ${docId} in workspace ${workspaceId} needs embedding: ${needEmbedding}`
);
if (needEmbedding) {
if (signal.aborted) return;
const fragment = await this.getDocFragment(workspaceId, docId);
@@ -389,9 +371,6 @@ export class CopilotContextDocJob {
error instanceof CopilotContextFileNotSupported &&
error.message.includes('no content found')
) {
this.logger.warn(
`Doc ${docId} in workspace ${workspaceId} has no content, fulfilling empty embedding.`
);
// if the doc is empty, we still need to fulfill the embedding
await this.fulfillEmptyEmbedding(workspaceId, docId);
return;
@@ -2,6 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import {
Cache,
Config,
CopilotInvalidContext,
NoCopilotProviderAvailable,
OnEvent,
@@ -14,11 +15,9 @@ import {
ContextFile,
Models,
} from '../../../models';
import { PromptService } from '../prompt';
import { CopilotProviderFactory } from '../providers';
import { getEmbeddingClient } from './embedding';
import { OpenAIEmbeddingClient } from './embedding';
import { ContextSession } from './session';
import type { EmbeddingClient } from './types';
import { EmbeddingClient } from './types';
const CONTEXT_SESSION_KEY = 'context-session';
@@ -28,24 +27,26 @@ export class CopilotContextService implements OnApplicationBootstrap {
private client: EmbeddingClient | undefined;
constructor(
private readonly config: Config,
private readonly cache: Cache,
private readonly models: Models,
private readonly providerFactory: CopilotProviderFactory,
private readonly prompt: PromptService
private readonly models: Models
) {}
@OnEvent('config.init')
async onConfigInit() {
await this.setup();
onConfigInit() {
this.setup();
}
@OnEvent('config.changed')
async onConfigChanged() {
await this.setup();
onConfigChanged() {
this.setup();
}
private async setup() {
this.client = await getEmbeddingClient(this.providerFactory, this.prompt);
private setup() {
const configure = this.config.copilot.providers.openai;
if (configure.apiKey) {
this.client = new OpenAIEmbeddingClient(configure);
}
}
async onApplicationBootstrap() {
@@ -69,10 +69,6 @@ export type Chunk = {
export const EMBEDDING_DIMENSIONS = 1024;
export abstract class EmbeddingClient {
async configured() {
return true;
}
async getFileEmbeddings(
file: File,
chunkMapper: (chunk: Chunk[]) => Chunk[],
@@ -335,66 +335,7 @@ Convert a multi-speaker audio recording into a structured JSON format by transcr
requireAttachment: true,
},
},
{
name: 'Rerank results',
action: 'Rerank results',
model: 'gpt-4.1-mini',
messages: [
{
role: 'system',
content: `Evaluate and rank search results based on their relevance and quality to the given query by assigning a score from 1 to 10, where 10 denotes the highest relevance.
Consider various factors such as content alignment with the query, source credibility, timeliness, and user intent.
# Steps
1. **Read the Query**: Understand the main intent and specific details of the search query.
2. **Review Each Result**:
- Analyze the content's relevance to the query.
- Assess the credibility of the source or website.
- Consider the timeliness of the information, ensuring it's current and relevant.
- Evaluate the alignment with potential user intent based on the query.
3. **Scoring**:
- Assign a score from 1 to 10 based on the overall relevance and quality, with 10 being the most relevant.
# Output Format
Return a JSON object for each result in the following format in raw:
{
"scores": [
{
"reason": "[Reasoning behind the score in 20 words]",
"chunk": "[chunk]",
"targetId": "[targetId]",
"score": [1-10]
}
]
}
# Notes
- Be aware of the potential biases or inaccuracies in the sources.
- Consider if the content is comprehensive and directly answers the query.
- Pay attention to the nuances of user intent that might influence relevance.`,
},
{
role: 'user',
content: `
<query>{{query}}</query>
<results>
{{#results}}
<result>
<targetId>{{targetId}}</targetId>
<chunk>{{chunk}}</chunk>
<content>
{{content}}
</content>
</result>
{{/results}}
</results>`,
},
],
},
{
name: 'Generate a caption',
action: 'Generate a caption',
@@ -1617,7 +1558,7 @@ const imageActions: Prompt[] = [
];
const CHAT_PROMPT: Omit<Prompt, 'name'> = {
model: 'claude-sonnet-4@20250514',
model: 'gpt-4.1',
optionalModels: [
'gpt-4.1',
'o3',
@@ -57,7 +57,8 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
try {
metrics.ai.counter('chat_text_calls').add(1, { model: model.id });
const [system, msgs] = await chatToGPTMessage(messages, true, true);
const [system, msgs] = await chatToGPTMessage(messages);
const modelInstance = this.instance(model.id);
const { text, reasoning } = await generateText({
@@ -93,8 +94,7 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
try {
metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id });
const [system, msgs] = await chatToGPTMessage(messages, true, true);
const [system, msgs] = await chatToGPTMessage(messages);
const { fullStream } = streamText({
model: this.instance(model.id),
system,
@@ -142,7 +142,7 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
}
private isReasoningModel(model: string) {
// claude 3.5 sonnet doesn't support reasoning config
return model.includes('sonnet') && !model.startsWith('claude-3-5-sonnet');
// only claude 3.7 sonnet supports reasoning config
return model.startsWith('claude-3-7-sonnet');
}
}
@@ -103,7 +103,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Structured],
output: [ModelOutputType.Text],
defaultForOutputType: true,
},
],
@@ -113,7 +113,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Structured],
output: [ModelOutputType.Text],
},
],
},
@@ -122,16 +122,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
{
id: 'gpt-4.1-nano',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Structured],
output: [ModelOutputType.Text],
},
],
},
@@ -292,8 +283,8 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
model: modelInstance,
system,
messages: msgs,
temperature: options.temperature ?? 0,
maxTokens: options.maxTokens ?? 4096,
temperature: options.temperature || 0,
maxTokens: options.maxTokens || 4096,
providerOptions: {
openai: this.getOpenAIOptions(options, model.id),
},
@@ -331,10 +322,10 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
model: modelInstance,
system,
messages: msgs,
frequencyPenalty: options.frequencyPenalty ?? 0,
presencePenalty: options.presencePenalty ?? 0,
temperature: options.temperature ?? 0,
maxTokens: options.maxTokens ?? 4096,
frequencyPenalty: options.frequencyPenalty || 0,
presencePenalty: options.presencePenalty || 0,
temperature: options.temperature || 0,
maxTokens: options.maxTokens || 4096,
providerOptions: {
openai: this.getOpenAIOptions(options, model.id),
},
@@ -397,9 +388,8 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
model: modelInstance,
system,
messages: msgs,
temperature: options.temperature ?? 0,
maxTokens: options.maxTokens ?? 4096,
maxRetries: options.maxRetries ?? 3,
temperature: ('temperature' in options && options.temperature) || 0,
maxTokens: ('maxTokens' in options && options.maxTokens) || 4096,
schema,
providerOptions: {
openai: options.user ? { user: options.user } : {},
@@ -124,8 +124,8 @@ export class PerplexityProvider extends CopilotProvider<PerplexityConfig> {
model: modelInstance,
system,
messages: msgs,
temperature: options.temperature ?? 0,
maxTokens: options.maxTokens ?? 4096,
temperature: options.temperature || 0,
maxTokens: options.maxTokens || 4096,
abortSignal: options.signal,
});
@@ -164,8 +164,8 @@ export class PerplexityProvider extends CopilotProvider<PerplexityConfig> {
model: modelInstance,
system,
messages: msgs,
temperature: options.temperature ?? 0,
maxTokens: options.maxTokens ?? 4096,
temperature: options.temperature || 0,
maxTokens: options.maxTokens || 4096,
abortSignal: options.signal,
});
@@ -172,7 +172,7 @@ export abstract class CopilotProvider<C = any> {
structure(
_cond: ModelConditions,
_messages: PromptMessage[],
_options?: CopilotStructuredOptions
_options: CopilotStructuredOptions
): Promise<string> {
throw new CopilotProviderNotSupported({
provider: this.type,
@@ -193,7 +193,7 @@ export abstract class CopilotProvider<C = any> {
embedding(
_model: ModelConditions,
_text: string | string[],
_text: string,
_options?: CopilotEmbeddingOptions
): Promise<number[][]> {
throw new CopilotProviderNotSupported({
@@ -61,8 +61,6 @@ export const PromptConfigStrictSchema = z.object({
// params requirements
requireContent: z.boolean().nullable().optional(),
requireAttachment: z.boolean().nullable().optional(),
// structure output
maxRetries: z.number().nullable().optional(),
// openai
frequencyPenalty: z.number().nullable().optional(),
presencePenalty: z.number().nullable().optional(),
@@ -64,10 +64,7 @@ export async function inferMimeType(url: string) {
export async function chatToGPTMessage(
messages: PromptMessage[],
// TODO(@darkskygit): move this logic in interface refactoring
withAttachment: boolean = true,
// NOTE: some providers in vercel ai sdk are not able to handle url attachments yet
// so we need to use base64 encoded attachments instead
useBase64Attachment: boolean = false
withAttachment: boolean = true
): Promise<[string | undefined, ChatMessage[], ZodType?]> {
const system = messages[0]?.role === 'system' ? messages.shift() : undefined;
const schema =
@@ -101,13 +98,12 @@ export async function chatToGPTMessage(
({ attachment, mimeType } = attachment);
}
if (SIMPLE_IMAGE_URL_REGEX.test(attachment)) {
const data =
attachment.startsWith('data:') || useBase64Attachment
if (mimeType.startsWith('image/')) {
contents.push({ type: 'image', image: attachment, mimeType });
} else {
const data = attachment.startsWith('data:')
? await fetch(attachment).then(r => r.arrayBuffer())
: new URL(attachment);
if (mimeType.startsWith('image/')) {
contents.push({ type: 'image', image: data, mimeType });
} else {
contents.push({ type: 'file' as const, data, mimeType });
}
}
@@ -19,6 +19,7 @@ import { ChatMessageCache } from './message';
import { PromptService } from './prompt';
import { PromptMessage, PromptParams } from './providers';
import {
AvailableModel,
ChatHistory,
ChatMessage,
ChatMessageSchema,
@@ -37,7 +38,7 @@ export class ChatSession implements AsyncDisposable {
private readonly messageCache: ChatMessageCache,
private readonly state: ChatSessionState,
private readonly dispose?: (state: ChatSessionState) => Promise<void>,
private readonly maxTokenSize = state.prompt.config?.maxTokens || 128 * 1024
private readonly maxTokenSize = 3840
) {}
get model() {
@@ -141,17 +142,17 @@ export class ChatSession implements AsyncDisposable {
}
private mergeUserContent(params: PromptParams) {
const messages = this.takeMessages();
const lastMessage = messages.pop();
const messages = this.stashMessages;
const firstMessage = messages.at(0);
if (
this.state.prompt.paramKeys.includes('content') &&
!messages.some(m => m.role === AiPromptRole.assistant) &&
lastMessage?.role === AiPromptRole.user
firstMessage
) {
const normalizedParams = {
...params,
...lastMessage.params,
content: lastMessage.content,
...firstMessage.params,
content: firstMessage.content,
};
const finished = this.state.prompt.finish(
normalizedParams,
@@ -159,16 +160,11 @@ export class ChatSession implements AsyncDisposable {
);
// attachments should be combined with the first user message
const firstUserMessageIndex = finished.findIndex(
m => m.role === AiPromptRole.user
);
// if prompt not contains user message, skip merge content
if (firstUserMessageIndex < 0) return null;
const firstUserMessage = finished[firstUserMessageIndex];
const firstUserMessage =
finished.find(m => m.role === 'user') || finished[0];
firstUserMessage.attachments = [
finished[0].attachments || [],
lastMessage.attachments || [],
firstMessage.attachments || [],
]
.flat()
.filter(v =>
@@ -176,8 +172,6 @@ export class ChatSession implements AsyncDisposable {
? !!v.trim()
: v && v.attachment.trim() && v.mimeType
);
//insert all previous user message content before first user message
finished.splice(firstUserMessageIndex, 0, ...messages);
return finished;
}
@@ -296,8 +290,8 @@ export class ChatSessionService {
messageCost: { increment: userMessages.length },
tokenCost: {
increment: this.calculateTokenSize(
state.messages,
state.prompt.model
userMessages,
state.prompt.model as AvailableModel
),
},
},
@@ -401,7 +395,10 @@ export class ChatSessionService {
});
}
private calculateTokenSize(messages: PromptMessage[], model: string): number {
private calculateTokenSize(
messages: PromptMessage[],
model: AvailableModel
): number {
const encoder = getTokenEncoder(model);
return messages
.map(m => encoder?.count(m.content) ?? 0)
@@ -47,24 +47,26 @@ export enum AvailableModels {
Gpt41 = 'gpt-4.1',
Gpt410414 = 'gpt-4.1-2025-04-14',
Gpt41Mini = 'gpt-4.1-mini',
Gpt41Nano = 'gpt-4.1-nano',
// embeddings
TextEmbedding3Large = 'text-embedding-3-large',
TextEmbedding3Small = 'text-embedding-3-small',
TextEmbeddingAda002 = 'text-embedding-ada-002',
// moderation
TextModerationLatest = 'text-moderation-latest',
TextModerationStable = 'text-moderation-stable',
// text to image
DallE3 = 'dall-e-3',
GptImage = 'gpt-image-1',
}
const availableModels = Object.values(AvailableModels);
export type AvailableModel = keyof typeof AvailableModels;
export function getTokenEncoder(model?: string | null): Tokenizer | null {
if (!model) return null;
if (!availableModels.includes(model as AvailableModels)) return null;
if (model.startsWith('gpt')) {
return fromModelName(model);
} else if (model.startsWith('dall')) {
const modelStr = AvailableModels[model as AvailableModel];
if (!modelStr) return null;
if (modelStr.startsWith('gpt')) {
return fromModelName(modelStr);
} else if (modelStr.startsWith('dall')) {
// dalle don't need to calc the token
return null;
} else {
@@ -13,13 +13,11 @@ import { ConnectedAccount } from '@prisma/client';
import type { Request, Response } from 'express';
import {
Config,
InvalidAuthState,
InvalidOauthCallbackState,
MissingOauthQueryParameter,
OauthAccountAlreadyConnected,
OauthStateExpired,
SignUpForbidden,
UnknownOauthProvider,
URLHelper,
UseNamedGuard,
@@ -40,8 +38,7 @@ export class OAuthController {
private readonly oauth: OAuthService,
private readonly models: Models,
private readonly providerFactory: OAuthProviderFactory,
private readonly url: URLHelper,
private readonly config: Config
private readonly url: URLHelper
) {}
@Public()
@@ -83,10 +80,8 @@ export class OAuthController {
};
}
// the prerequest `/oauth/prelight` request already checked client version,
// let's simply ignore it for callback which will block apple oauth post_form mode
// @UseNamedGuard('version')
@Public()
@UseNamedGuard('version')
@Post('/callback')
@HttpCode(HttpStatus.OK)
async callback(
@@ -187,7 +182,7 @@ export class OAuthController {
}
const externAccount = await provider.getUser(tokens, state);
const user = await this.getOrCreateUserFromOauth(
const user = await this.loginFromOauth(
state.provider,
externAccount,
tokens
@@ -208,7 +203,7 @@ export class OAuthController {
});
}
private async getOrCreateUserFromOauth(
private async loginFromOauth(
provider: OAuthProviderName,
externalAccount: OAuthAccount,
tokens: Tokens
@@ -224,10 +219,6 @@ export class OAuthController {
return connectedAccount.user;
}
if (!this.config.auth.allowSignup) {
throw new SignUpForbidden();
}
const user = await this.models.user.fulfill(externalAccount.email, {
avatarUrl: externalAccount.avatarUrl,
});
@@ -7,7 +7,6 @@ import { OAuthProviderFactory } from '../factory';
export interface OAuthAccount {
id: string;
email: string;
name?: string;
avatarUrl?: string;
}
+2 -2
View File
@@ -262,12 +262,12 @@ enum CopilotModels {
Gpt4OmniMini0718
Gpt41
Gpt41Mini
Gpt41Nano
Gpt410414
GptImage
TextEmbedding3Large
TextEmbedding3Small
TextEmbeddingAda002
TextModerationLatest
TextModerationStable
}
input CopilotPromptConfigInput {
+2 -2
View File
@@ -356,12 +356,12 @@ export enum CopilotModels {
Gpt4OmniMini0718 = 'Gpt4OmniMini0718',
Gpt41 = 'Gpt41',
Gpt41Mini = 'Gpt41Mini',
Gpt41Nano = 'Gpt41Nano',
Gpt410414 = 'Gpt410414',
GptImage = 'GptImage',
TextEmbedding3Large = 'TextEmbedding3Large',
TextEmbedding3Small = 'TextEmbedding3Small',
TextEmbeddingAda002 = 'TextEmbeddingAda002',
TextModerationLatest = 'TextModerationLatest',
TextModerationStable = 'TextModerationStable',
}
export interface CopilotPromptConfigInput {
+1 -1
View File
@@ -21,7 +21,7 @@
},
"license": "MIT",
"devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.81",
"@napi-rs/cli": "3.0.0-alpha.78",
"@types/node": "^22.14.1",
"@types/prompts": "^2.4.9",
"c8": "^10.1.3",
+6 -6
View File
@@ -1,5 +1,5 @@
use napi::{
bindgen_prelude::{Array as JsArray, Env, JsObjectValue, JsValue, Null, ToNapiValue, Unknown},
bindgen_prelude::{Array as JsArray, Env, Unknown},
ValueType,
};
use y_octo::{Any, Array, Value};
@@ -34,18 +34,18 @@ impl YArray {
}
#[napi(ts_generic_types = "T = unknown", ts_return_type = "T")]
pub fn get<'a>(&'a self, env: &'a Env, index: i64) -> Result<MixedYType<'a>> {
pub fn get(&self, env: Env, index: i64) -> Result<MixedYType> {
if let Some(value) = self.array.get(index as u64) {
match value {
Value::Any(any) => get_js_unknown_from_any(env, any).map(MixedYType::D),
Value::Array(array) => Ok(MixedYType::A(YArray::inner_new(array))),
Value::Map(map) => Ok(MixedYType::B(YMap::inner_new(map))),
Value::Text(text) => Ok(MixedYType::C(YText::inner_new(text))),
_ => Null.into_unknown(env).map(MixedYType::D),
_ => env.get_null().map(|v| v.into_unknown()).map(MixedYType::D),
}
.map_err(anyhow::Error::from)
} else {
Ok(MixedYType::D(Null.into_unknown(env)?))
Ok(MixedYType::D(env.get_null()?.into_unknown()))
}
}
@@ -73,7 +73,7 @@ impl YArray {
.array
.insert(index as u64, Any::Null)
.map_err(anyhow::Error::from),
ValueType::Boolean => match unsafe { unknown.cast::<bool>() } {
ValueType::Boolean => match unknown.coerce_to_bool().and_then(|v| v.get_value()) {
Ok(boolean) => self
.array
.insert(index as u64, boolean)
@@ -140,7 +140,7 @@ impl YArray {
}
#[napi]
pub fn to_json<'env>(&'env self, env: &'env Env) -> Result<JsArray<'env>> {
pub fn to_json(&self, env: Env) -> Result<JsArray> {
let mut js_array = env.create_array(0)?;
for value in self.array.iter() {
js_array.insert(get_js_unknown_from_value(env, value)?)?;
+7 -7
View File
@@ -1,4 +1,4 @@
use napi::bindgen_prelude::{Env, JsValue, Null, Object, ToNapiValue, ValueType};
use napi::bindgen_prelude::{Env, Object, ValueType};
use y_octo::{Any, Map, Value};
use super::*;
@@ -25,18 +25,18 @@ impl YMap {
}
#[napi(ts_generic_types = "T = unknown", ts_return_type = "T")]
pub fn get<'a>(&'a self, env: &'a Env, key: String) -> Result<MixedYType<'a>> {
pub fn get(&self, env: Env, key: String) -> Result<MixedYType> {
if let Some(value) = self.map.get(&key) {
match value {
Value::Any(any) => get_js_unknown_from_any(env, any).map(MixedYType::D),
Value::Array(array) => Ok(MixedYType::A(YArray::inner_new(array))),
Value::Map(map) => Ok(MixedYType::B(YMap::inner_new(map))),
Value::Text(text) => Ok(MixedYType::C(YText::inner_new(text))),
_ => Null.into_unknown(env).map(MixedYType::D),
_ => env.get_null().map(|v| v.into_unknown()).map(MixedYType::D),
}
.map_err(anyhow::Error::from)
} else {
Ok(MixedYType::D(Null.into_unknown(env)?))
Ok(MixedYType::D(env.get_null()?.into_unknown()))
}
}
@@ -63,7 +63,7 @@ impl YMap {
ValueType::Undefined | ValueType::Null => {
self.map.insert(key, Any::Null).map_err(anyhow::Error::from)
}
ValueType::Boolean => match unsafe { unknown.cast::<bool>() } {
ValueType::Boolean => match unknown.coerce_to_bool().and_then(|v| v.get_value()) {
Ok(boolean) => self.map.insert(key, boolean).map_err(anyhow::Error::from),
Err(e) => Err(anyhow::Error::from(e).context("Failed to coerce value to boolean")),
},
@@ -106,9 +106,9 @@ impl YMap {
#[napi]
pub fn to_json(&self, env: Env) -> Result<Object> {
let mut js_object = Object::new(&env)?;
let mut js_object = env.create_object()?;
for (key, value) in self.map.iter() {
js_object.set(key, get_js_unknown_from_value(&env, value))?;
js_object.set(key, get_js_unknown_from_value(env, value))?;
}
Ok(js_object)
}
+36 -30
View File
@@ -1,39 +1,38 @@
use napi::bindgen_prelude::{
Array, Either4, Env, Error, External, JsObjectValue, JsValue, Null, Object, Result, Status,
ToNapiValue, Unknown, ValueType,
use napi::{
bindgen_prelude::{
Either4, Env, Error, External, Object, Result, Status, ToNapiValue, Unknown, ValueType,
},
NapiValue,
};
use y_octo::{AHashMap, Any, HashMapExt, Value};
use super::*;
pub type MixedYType<'a> = Either4<YArray, YMap, YText, Unknown<'a>>;
pub type MixedRefYType<'a> = Either4<&'a YArray, &'a YMap, &'a YText, Unknown<'a>>;
pub type MixedYType = Either4<YArray, YMap, YText, Unknown>;
pub type MixedRefYType<'a> = Either4<&'a YArray, &'a YMap, &'a YText, Unknown>;
pub fn get_js_unknown_from_any(env: &Env, any: Any) -> Result<Unknown> {
pub fn get_js_unknown_from_any(env: Env, any: Any) -> Result<Unknown> {
match any {
Any::Null | Any::Undefined => Null.into_unknown(env),
Any::True => true.into_unknown(env),
Any::False => false.into_unknown(env),
Any::Integer(number) => number.into_unknown(env),
Any::BigInt64(number) => number.into_unknown(env),
Any::Float32(number) => number.0.into_unknown(env),
Any::Float64(number) => number.0.into_unknown(env),
Any::String(string) => string.into_unknown(env),
Any::Null | Any::Undefined => env.get_null().map(|v| v.into_unknown()),
Any::True => env.get_boolean(true).map(|v| v.into_unknown()),
Any::False => env.get_boolean(false).map(|v| v.into_unknown()),
Any::Integer(number) => env.create_int32(number).map(|v| v.into_unknown()),
Any::BigInt64(number) => env.create_int64(number).map(|v| v.into_unknown()),
Any::Float32(number) => env.create_double(number.0 as f64).map(|v| v.into_unknown()),
Any::Float64(number) => env.create_double(number.0).map(|v| v.into_unknown()),
Any::String(string) => env.create_string(string.as_str()).map(|v| v.into_unknown()),
Any::Array(array) => {
let js_array = Array::from_vec(
env,
array
.into_iter()
.map(|value| get_js_unknown_from_any(env, value))
.collect::<Result<Vec<Unknown>>>()?,
)?;
Ok(js_array.to_unknown())
let mut js_array = env.create_array_with_length(array.len())?;
for (i, value) in array.into_iter().enumerate() {
js_array.set_element(i as u32, get_js_unknown_from_any(env, value)?)?;
}
Ok(js_array.into_unknown())
}
_ => Null.into_unknown(env),
_ => env.get_null().map(|v| v.into_unknown()),
}
}
pub fn get_js_unknown_from_value(env: &Env, value: Value) -> Result<Unknown> {
pub fn get_js_unknown_from_value(env: Env, value: Value) -> Result<Unknown> {
match value {
Value::Any(any) => get_js_unknown_from_any(env, any),
Value::Array(array) => {
@@ -50,9 +49,11 @@ pub fn get_js_unknown_from_value(env: &Env, value: Value) -> Result<Unknown> {
}
Value::Text(text) => {
let external = External::new(YText::inner_new(text));
external.into_unknown(env)
Ok(unsafe {
Unknown::from_raw_unchecked(env.raw(), ToNapiValue::to_napi_value(env.raw(), external)?)
})
}
_ => Null.into_unknown(env),
_ => env.get_null().map(|v| v.into_unknown()),
}
}
@@ -70,14 +71,14 @@ pub fn get_any_from_js_object(object: Object) -> Result<Any> {
let keys = object.get_property_names()?;
if let Ok(length) = keys.get_array_length() {
for i in 0..length {
if let Ok(key) = keys.get_element::<Unknown>(i).and_then(|o| {
if let Ok((obj, key)) = keys.get_element::<Unknown>(i).and_then(|o| {
o.coerce_to_string().and_then(|obj| {
obj
.into_utf8()
.and_then(|s| s.as_str().map(|s| s.to_string()))
.and_then(|s| s.as_str().map(|s| (obj, s.to_string())))
})
}) {
if let Ok(value) = object.get_named_property_unchecked::<Unknown>(&key) {
if let Ok(value) = object.get_property::<_, Unknown>(obj) {
println!("key: {}", key);
map.insert(key, get_any_from_js_unknown(value)?);
}
@@ -91,7 +92,12 @@ pub fn get_any_from_js_object(object: Object) -> Result<Any> {
pub fn get_any_from_js_unknown(js_unknown: Unknown) -> Result<Any> {
match js_unknown.get_type()? {
ValueType::Undefined | ValueType::Null => Ok(Any::Null),
ValueType::Boolean => Ok(unsafe { js_unknown.cast::<bool>()? }.into()),
ValueType::Boolean => Ok(
js_unknown
.coerce_to_bool()
.and_then(|v| v.get_value())?
.into(),
),
ValueType::Number => Ok(
js_unknown
.coerce_to_number()
@@ -128,9 +128,6 @@ dependencies {
implementation libs.okhttp.logging
implementation libs.okhttp.sse
implementation libs.richtext.commonmark
implementation libs.richtext.ui
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
@@ -139,9 +136,6 @@ dependencies {
cargo {
module = "../../../../mobile-native"
libname = "affine_mobile_native"
features {
defaultAnd("use-as-lib")
}
targets = ["arm64"]
pythonCommand = "python3"
targetDirectory = "../../../../../../target"
@@ -8,7 +8,7 @@
</queries>
<application
android:name=".AFFiNEApp"
android:name=".AffineApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -13,7 +13,7 @@ import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@HiltAndroidApp
class AFFiNEApp : Application() {
class AffineApp : Application() {
override fun onCreate() {
super.onCreate()
@@ -24,9 +24,9 @@ object AuthInitializer {
MainScope().launch(Dispatchers.IO) {
try {
val server = bridge.getCurrentServerBaseUrl().toHttpUrl()
val sessionCookieStr = AFFiNEApp.context().dataStore
val sessionCookieStr = AffineApp.context().dataStore
.get(server.host + CookieStore.AFFINE_SESSION)
val userIdCookieStr = AFFiNEApp.context().dataStore
val userIdCookieStr = AffineApp.context().dataStore
.get(server.host + CookieStore.AFFINE_USER_ID)
if (sessionCookieStr.isEmpty() || userIdCookieStr.isEmpty()) {
Timber.i("[init] user has not signed in yet.")
@@ -6,15 +6,13 @@ import android.view.Gravity
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateMargins
import androidx.lifecycle.lifecycleScope
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import app.affine.pro.ai.AIActivity
import app.affine.pro.plugin.AIButtonPlugin
import app.affine.pro.plugin.AFFiNEThemePlugin
import app.affine.pro.plugin.AffineThemePlugin
import app.affine.pro.plugin.AuthPlugin
import app.affine.pro.plugin.HashCashPlugin
import app.affine.pro.plugin.NbStorePlugin
@@ -31,7 +29,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugin.Callback,
class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugin.Callback,
View.OnClickListener {
@Inject
@@ -46,7 +44,7 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
init {
registerPlugins(
listOf(
AFFiNEThemePlugin::class.java,
AffineThemePlugin::class.java,
AIButtonPlugin::class.java,
AuthPlugin::class.java,
HashCashPlugin::class.java,
@@ -64,13 +62,6 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
}
customSize = dp2px(52)
setImageResource(R.drawable.ic_ai)
setImageDrawable(
VectorDrawableCompat.create(resources, R.drawable.ic_ai, theme)?.apply {
DrawableCompat.setTint(
this,
ContextCompat.getColor(context, R.color.affine_primary)
)
})
setOnClickListener(this@MainActivity)
val parent = bridge.webView.parent as CoordinatorLayout
parent.addView(this)

Some files were not shown because too many files have changed in this diff Show More