chore: merge blocksuite source code (#9213)
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,18 @@
|
||||
import { beforeEach, expect, test } from 'vitest';
|
||||
|
||||
import { getSurface } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('basic assert', () => {
|
||||
expect(window.doc).toBeDefined();
|
||||
expect(window.editor).toBeDefined();
|
||||
expect(window.editor.mode).toBe('edgeless');
|
||||
|
||||
expect(getSurface(window.doc, window.editor)).toBeDefined();
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import '@toeverything/theme/style.css';
|
||||
|
||||
import {
|
||||
ColorScheme,
|
||||
type EdgelessRootBlockComponent,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/blocks';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { getDocRootBlock } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
describe('theme service', () => {
|
||||
let edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
|
||||
edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
||||
|
||||
edgeless.gfx.tool.setTool('default');
|
||||
|
||||
const themeService = edgeless.gfx.std.get(ThemeProvider);
|
||||
themeService.theme$.value = ColorScheme.Light;
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('switches theme', () => {
|
||||
const themeService = edgeless.gfx.std.get(ThemeProvider);
|
||||
expect(themeService.theme).toBe(ColorScheme.Light);
|
||||
|
||||
themeService.theme$.value = ColorScheme.Dark;
|
||||
expect(themeService.theme).toBe(ColorScheme.Dark);
|
||||
|
||||
themeService.theme$.value = ColorScheme.Light;
|
||||
expect(themeService.theme).toBe(ColorScheme.Light);
|
||||
});
|
||||
|
||||
test('generates a color property', () => {
|
||||
const themeService = edgeless.gfx.std.get(ThemeProvider);
|
||||
expect(themeService.theme).toBe(ColorScheme.Light);
|
||||
|
||||
expect(themeService.generateColorProperty('--affine-hover-color')).toBe(
|
||||
'var(--affine-hover-color)'
|
||||
);
|
||||
|
||||
expect(themeService.generateColorProperty('--affine-transparent')).toBe(
|
||||
'transparent'
|
||||
);
|
||||
|
||||
expect(themeService.generateColorProperty('transparent')).toBe(
|
||||
'transparent'
|
||||
);
|
||||
|
||||
expect(
|
||||
themeService.generateColorProperty({ dark: 'white', light: 'black' })
|
||||
).toBe('black');
|
||||
|
||||
themeService.theme$.value = ColorScheme.Dark;
|
||||
expect(themeService.theme).toBe(ColorScheme.Dark);
|
||||
|
||||
expect(
|
||||
themeService.generateColorProperty({ dark: 'white', light: 'black' })
|
||||
).toBe('white');
|
||||
|
||||
expect(themeService.generateColorProperty({ normal: 'grey' })).toBe('grey');
|
||||
|
||||
expect(themeService.generateColorProperty('', 'blue')).toBe('blue');
|
||||
expect(themeService.generateColorProperty({}, 'red')).toBe('red');
|
||||
});
|
||||
|
||||
test('gets a color value', () => {
|
||||
const themeService = edgeless.gfx.std.get(ThemeProvider);
|
||||
expect(themeService.theme).toBe(ColorScheme.Light);
|
||||
|
||||
expect(themeService.getColorValue('--affine-transparent')).toBe(
|
||||
'--affine-transparent'
|
||||
);
|
||||
expect(
|
||||
themeService.getColorValue('--affine-transparent', 'transparent', true)
|
||||
).toBe('transparent');
|
||||
expect(
|
||||
themeService.getColorValue('--affine-hover-color', 'transparent', true)
|
||||
).toBe('rgba(0, 0, 0, 0.04)');
|
||||
expect(
|
||||
themeService.getColorValue('--affine-tooltip', undefined, true)
|
||||
).toBe('rgba(0, 0, 0, 1)');
|
||||
|
||||
expect(
|
||||
themeService.getColorValue(
|
||||
{ dark: 'white', light: 'black' },
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
).toBe('black');
|
||||
expect(
|
||||
themeService.getColorValue({ dark: 'white', light: '' }, undefined, true)
|
||||
).toBe('transparent');
|
||||
expect(
|
||||
themeService.getColorValue({ normal: 'grey' }, undefined, true)
|
||||
).toBe('grey');
|
||||
|
||||
themeService.theme$.value = ColorScheme.Dark;
|
||||
expect(themeService.theme).toBe(ColorScheme.Dark);
|
||||
|
||||
expect(
|
||||
themeService.getColorValue('--affine-hover-color', 'transparent', true)
|
||||
).toEqual('rgba(255, 255, 255, 0.1)');
|
||||
expect(
|
||||
themeService.getColorValue('--affine-tooltip', undefined, true)
|
||||
).toEqual('rgba(234, 234, 234, 1)'); // #eaeaea
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
AffineFrameTitleWidget,
|
||||
EdgelessRootBlockComponent,
|
||||
FrameBlockComponent,
|
||||
FrameBlockModel,
|
||||
} from '@blocksuite/blocks';
|
||||
import { assertType } from '@blocksuite/global/utils';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { wait } from '../utils/common.js';
|
||||
import { getDocRootBlock } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
describe('frame', () => {
|
||||
let service!: EdgelessRootBlockComponent['service'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
service = getDocRootBlock(window.doc, window.editor, 'edgeless').service;
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('frame should have title', async () => {
|
||||
const frame = service.doc.addBlock(
|
||||
'affine:frame',
|
||||
{
|
||||
xywh: '[0,0,300,300]',
|
||||
title: new Text('Frame 1'),
|
||||
},
|
||||
service.surface.id
|
||||
);
|
||||
await wait();
|
||||
|
||||
const frameTitleWidget = service.std.view.getWidget(
|
||||
'affine-frame-title-widget',
|
||||
doc.root!.id
|
||||
) as AffineFrameTitleWidget | null;
|
||||
|
||||
const frameTitle = frameTitleWidget?.getFrameTitle(frame);
|
||||
const rect = frameTitle?.getBoundingClientRect();
|
||||
|
||||
expect(frameTitle).toBeTruthy();
|
||||
expect(rect).toBeTruthy();
|
||||
expect(rect!.width).toBeGreaterThan(0);
|
||||
expect(rect!.height).toBeGreaterThan(0);
|
||||
|
||||
const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y);
|
||||
expect(titleX).toBeCloseTo(0);
|
||||
expect(titleY).toBeLessThan(0);
|
||||
|
||||
const nestedFrame = service.doc.addBlock(
|
||||
'affine:frame',
|
||||
{
|
||||
xywh: '[20,20,200,200]',
|
||||
title: new Text('Frame 2'),
|
||||
},
|
||||
service.surface.id
|
||||
);
|
||||
await wait();
|
||||
|
||||
const nestedTitle = frameTitleWidget?.getFrameTitle(nestedFrame);
|
||||
expect(nestedTitle).toBeTruthy();
|
||||
if (!nestedTitle) return;
|
||||
|
||||
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
|
||||
const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord(
|
||||
nestedTitleRect.x,
|
||||
nestedTitleRect.y
|
||||
);
|
||||
|
||||
expect(nestedTitleX).toBeGreaterThan(20);
|
||||
expect(nestedTitleY).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
test('frame should have externalXYWH after moving viewport to contains frame', async () => {
|
||||
const frameId = service.doc.addBlock(
|
||||
'affine:frame',
|
||||
{
|
||||
xywh: '[1800,1800,200,200]',
|
||||
title: new Text('Frame 1'),
|
||||
},
|
||||
service.surface.id
|
||||
);
|
||||
await wait();
|
||||
|
||||
const frame = service.doc.getBlock(frameId);
|
||||
expect(frame).toBeTruthy();
|
||||
|
||||
assertType<FrameBlockComponent>(frame);
|
||||
|
||||
service.viewport.setCenter(900, 900);
|
||||
expect(frame?.model.externalXYWH).toBeDefined();
|
||||
});
|
||||
|
||||
test('descendant of frame should not contain itself', async () => {
|
||||
const frameIds = [1, 2, 3].map(i => {
|
||||
return service.doc.addBlock(
|
||||
'affine:frame',
|
||||
{
|
||||
xywh: '[0,0,300,300]',
|
||||
title: new Text(`Frame ${i}`),
|
||||
},
|
||||
service.surface.id
|
||||
);
|
||||
});
|
||||
|
||||
await wait();
|
||||
|
||||
const frames = frameIds.map(
|
||||
id => service.doc.getBlock(id)?.model as FrameBlockModel
|
||||
);
|
||||
|
||||
frames.forEach(frame => {
|
||||
expect(frame.descendantElements).toHaveLength(0);
|
||||
});
|
||||
|
||||
frames[0].addChild(frames[1]);
|
||||
frames[1].addChild(frames[2]);
|
||||
frames[2].addChild(frames[0]);
|
||||
|
||||
await wait();
|
||||
expect(frames[0].descendantElements).toHaveLength(2);
|
||||
expect(frames[1].descendantElements).toHaveLength(1);
|
||||
expect(frames[2].descendantElements).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { MindmapElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type EdgelessRootBlockComponent,
|
||||
type GroupElementModel,
|
||||
LayoutType,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/blocks';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { wait } from '../utils/common.js';
|
||||
import { addNote, getDocRootBlock } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
describe('group', () => {
|
||||
let service!: EdgelessRootBlockComponent['service'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
service = getDocRootBlock(window.doc, window.editor, 'edgeless').service;
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('group with no children will be removed automatically', () => {
|
||||
const map = new DocCollection.Y.Map<boolean>();
|
||||
const ids = Array.from({ length: 2 })
|
||||
.map(() => {
|
||||
const id = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
map.set(id, true);
|
||||
|
||||
return id;
|
||||
})
|
||||
.concat(
|
||||
Array.from({ length: 2 }).map(() => {
|
||||
const id = addNote(doc);
|
||||
map.set(id, true);
|
||||
return id;
|
||||
})
|
||||
);
|
||||
service.addElement('group', { children: map });
|
||||
doc.captureSync();
|
||||
expect(service.elements.length).toBe(3);
|
||||
|
||||
service.removeElement(ids[0]);
|
||||
service.removeElement(ids[1]);
|
||||
doc.captureSync();
|
||||
expect(service.elements.length).toBe(1);
|
||||
|
||||
service.removeElement(ids[2]);
|
||||
service.removeElement(ids[3]);
|
||||
doc.captureSync();
|
||||
expect(service.elements.length).toBe(0);
|
||||
|
||||
doc.undo();
|
||||
expect(service.elements.length).toBe(1);
|
||||
doc.redo();
|
||||
expect(service.elements.length).toBe(0);
|
||||
});
|
||||
|
||||
test('remove group should remove its children at the same time', () => {
|
||||
const map = new DocCollection.Y.Map<boolean>();
|
||||
const doc = service.doc;
|
||||
const noteId = addNote(doc);
|
||||
const shapeId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
|
||||
map.set(noteId, true);
|
||||
map.set(shapeId, true);
|
||||
const groupId = service.addElement('group', { children: map });
|
||||
|
||||
expect(service.elements.length).toBe(2);
|
||||
expect(doc.getBlock(noteId)).toBeDefined();
|
||||
doc.captureSync();
|
||||
|
||||
service.removeElement(groupId);
|
||||
expect(service.elements.length).toBe(0);
|
||||
expect(doc.getBlock(noteId)).toBeUndefined();
|
||||
|
||||
doc.undo();
|
||||
expect(doc.getBlock(noteId)).toBeDefined();
|
||||
expect(service.elements.length).toBe(2);
|
||||
});
|
||||
|
||||
test("group's xywh should update automatically when children change", async () => {
|
||||
const shape1 = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
xywh: '[0,0,100,100]',
|
||||
});
|
||||
const shape2 = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
xywh: '[100,100,100,100]',
|
||||
});
|
||||
const note1 = addNote(doc, {
|
||||
displayMode: NoteDisplayMode.DocAndEdgeless,
|
||||
xywh: '[200,200,800,100]',
|
||||
edgeless: {
|
||||
style: {
|
||||
borderRadius: 8,
|
||||
borderSize: 4,
|
||||
borderStyle: 'solid',
|
||||
shadowType: '--affine-note-shadow-box',
|
||||
},
|
||||
collapse: true,
|
||||
collapsedHeight: 100,
|
||||
},
|
||||
});
|
||||
const children = new DocCollection.Y.Map<boolean>();
|
||||
|
||||
children.set(shape1, true);
|
||||
children.set(shape2, true);
|
||||
children.set(note1, true);
|
||||
|
||||
const groupId = service.addElement('group', { children });
|
||||
const group = service.getElementById(groupId) as GroupElementModel;
|
||||
const assertInitial = () => {
|
||||
expect(group.x).toBe(0);
|
||||
expect(group.y).toBe(0);
|
||||
expect(group.w).toBe(1000);
|
||||
expect(group.h).toBe(300);
|
||||
};
|
||||
|
||||
doc.captureSync();
|
||||
await wait();
|
||||
assertInitial();
|
||||
|
||||
service.removeElement(note1);
|
||||
await wait();
|
||||
expect(group.x).toBe(0);
|
||||
expect(group.y).toBe(0);
|
||||
expect(group.w).toBe(200);
|
||||
expect(group.h).toBe(200);
|
||||
doc.captureSync();
|
||||
|
||||
doc.undo();
|
||||
await wait();
|
||||
assertInitial();
|
||||
|
||||
service.updateElement(note1, {
|
||||
xywh: '[300,300,800,100]',
|
||||
});
|
||||
await wait();
|
||||
expect(group.x).toBe(0);
|
||||
expect(group.y).toBe(0);
|
||||
expect(group.w).toBe(1100);
|
||||
expect(group.h).toBe(400);
|
||||
doc.captureSync();
|
||||
|
||||
doc.undo();
|
||||
await wait();
|
||||
assertInitial();
|
||||
|
||||
service.removeElement(shape1);
|
||||
await wait();
|
||||
expect(group.x).toBe(100);
|
||||
expect(group.y).toBe(100);
|
||||
expect(group.w).toBe(900);
|
||||
expect(group.h).toBe(200);
|
||||
doc.captureSync();
|
||||
|
||||
doc.undo();
|
||||
await wait();
|
||||
assertInitial();
|
||||
|
||||
service.updateElement(shape1, {
|
||||
xywh: '[100,100,100,100]',
|
||||
});
|
||||
await wait();
|
||||
expect(group.x).toBe(100);
|
||||
expect(group.y).toBe(100);
|
||||
expect(group.w).toBe(900);
|
||||
expect(group.h).toBe(200);
|
||||
doc.captureSync();
|
||||
|
||||
doc.undo();
|
||||
await wait();
|
||||
assertInitial();
|
||||
});
|
||||
|
||||
test('empty group should have all zero xywh', () => {
|
||||
const map = new DocCollection.Y.Map<boolean>();
|
||||
const groupId = service.addElement('group', { children: map });
|
||||
const group = service.getElementById(groupId) as GroupElementModel;
|
||||
|
||||
expect(group.x).toBe(0);
|
||||
expect(group.y).toBe(0);
|
||||
expect(group.w).toBe(0);
|
||||
expect(group.h).toBe(0);
|
||||
});
|
||||
|
||||
test('descendant of group should not contain itself', () => {
|
||||
const groupIds = [1, 2, 3].map(_ => {
|
||||
return service.addElement('group', {
|
||||
children: new DocCollection.Y.Map<boolean>(),
|
||||
});
|
||||
});
|
||||
const groups = groupIds.map(
|
||||
id => service.getElementById(id) as GroupElementModel
|
||||
);
|
||||
|
||||
groups.forEach(group => {
|
||||
expect(group.descendantElements).toHaveLength(0);
|
||||
});
|
||||
|
||||
groups[0].addChild(groups[1]);
|
||||
groups[1].addChild(groups[2]);
|
||||
groups[2].addChild(groups[0]);
|
||||
|
||||
expect(groups[0].descendantElements).toHaveLength(2);
|
||||
expect(groups[1].descendantElements).toHaveLength(1);
|
||||
expect(groups[2].descendantElements).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mindmap', () => {
|
||||
let service!: EdgelessRootBlockComponent['service'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
service = getDocRootBlock(window.doc, window.editor, 'edgeless').service;
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('delete the root node should remove all children', async () => {
|
||||
const tree = {
|
||||
text: 'root',
|
||||
children: [
|
||||
{
|
||||
text: 'leaf1',
|
||||
},
|
||||
{
|
||||
text: 'leaf2',
|
||||
},
|
||||
{
|
||||
text: 'leaf3',
|
||||
children: [
|
||||
{
|
||||
text: 'leaf4',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const mindmapId = service.addElement('mindmap', { children: tree });
|
||||
const mindmap = () =>
|
||||
service.getElementById(mindmapId) as MindmapElementModel;
|
||||
|
||||
expect(service.surface.elementModels.length).toBe(6);
|
||||
doc.captureSync();
|
||||
|
||||
service.removeElement(mindmap().tree.element);
|
||||
await wait();
|
||||
expect(service.surface.elementModels.length).toBe(0);
|
||||
doc.captureSync();
|
||||
await wait();
|
||||
|
||||
doc.undo();
|
||||
expect(service.surface.elementModels.length).toBe(6);
|
||||
await wait();
|
||||
|
||||
service.removeElement(mindmap().tree.children[2].element);
|
||||
await wait();
|
||||
expect(service.surface.elementModels.length).toBe(4);
|
||||
await wait();
|
||||
|
||||
doc.undo();
|
||||
await wait();
|
||||
expect(service.surface.elementModels.length).toBe(6);
|
||||
});
|
||||
|
||||
test('mindmap should layout automatically when creating', async () => {
|
||||
const tree = {
|
||||
text: 'root',
|
||||
children: [
|
||||
{
|
||||
text: 'leaf1',
|
||||
},
|
||||
{
|
||||
text: 'leaf2',
|
||||
},
|
||||
{
|
||||
text: 'leaf3',
|
||||
children: [
|
||||
{
|
||||
text: 'leaf4',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const mindmapId = service.addElement('mindmap', {
|
||||
type: LayoutType.RIGHT,
|
||||
children: tree,
|
||||
});
|
||||
const mindmap = () =>
|
||||
service.getElementById(mindmapId) as MindmapElementModel;
|
||||
|
||||
doc.captureSync();
|
||||
await wait();
|
||||
|
||||
const root = mindmap().tree.element;
|
||||
const children = mindmap().tree.children.map(child => child.element);
|
||||
const leaf4 = mindmap().tree.children[2].children[0].element;
|
||||
|
||||
expect(children[0].x).greaterThan(root.x + root.w);
|
||||
expect(children[1].x).greaterThan(root.x + root.w);
|
||||
expect(children[2].x).greaterThan(root.x + root.w);
|
||||
|
||||
expect(children[1].y).greaterThan(children[0].y + children[0].h);
|
||||
expect(children[2].y).greaterThan(children[1].y + children[1].h);
|
||||
|
||||
expect(leaf4.x).greaterThan(children[2].x + children[2].w);
|
||||
});
|
||||
|
||||
test('deliberately creating a circular reference should be resolved correctly', async () => {
|
||||
const tree = {
|
||||
text: 'root',
|
||||
children: [
|
||||
{
|
||||
text: 'leaf1',
|
||||
},
|
||||
{
|
||||
text: 'leaf2',
|
||||
},
|
||||
{
|
||||
text: 'leaf3',
|
||||
children: [
|
||||
{
|
||||
text: 'leaf4',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const mindmapId = service.addElement('mindmap', {
|
||||
type: LayoutType.RIGHT,
|
||||
children: tree,
|
||||
});
|
||||
const mindmap = () =>
|
||||
service.getElementById(mindmapId) as MindmapElementModel;
|
||||
|
||||
doc.captureSync();
|
||||
await wait();
|
||||
|
||||
// create a circular reference
|
||||
doc.transact(() => {
|
||||
const root = mindmap().tree;
|
||||
const leaf3 = root.children[2];
|
||||
const leaf4 = root.children[2].children[0];
|
||||
|
||||
mindmap().children.set(leaf3.id, {
|
||||
index: leaf3.detail.index,
|
||||
parent: leaf4.id,
|
||||
});
|
||||
});
|
||||
doc.captureSync();
|
||||
|
||||
await wait();
|
||||
|
||||
// the circular referenced node should be removed
|
||||
expect(mindmap().nodeMap.size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import {
|
||||
type BrushElementModel,
|
||||
type ConnectorElementModel,
|
||||
DEFAULT_NOTE_BACKGROUND_COLOR,
|
||||
DEFAULT_NOTE_SHADOW,
|
||||
DEFAULT_TEXT_COLOR,
|
||||
type EdgelessRootBlockComponent,
|
||||
type EdgelessTextBlockModel,
|
||||
EditPropsStore,
|
||||
FontFamily,
|
||||
FrameBackgroundColor,
|
||||
type FrameBlockModel,
|
||||
getSurfaceBlock,
|
||||
LayoutType,
|
||||
LineColor,
|
||||
type MindmapElementModel,
|
||||
MindmapStyle,
|
||||
NoteBackgroundColor,
|
||||
type NoteBlockModel,
|
||||
NoteShadow,
|
||||
type ShapeElementModel,
|
||||
ShapeFillColor,
|
||||
ShapeType,
|
||||
type TextElementModel,
|
||||
} from '@blocksuite/blocks';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { getDocRootBlock } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
describe('apply last props', () => {
|
||||
let edgelessRoot!: EdgelessRootBlockComponent;
|
||||
let service!: EdgelessRootBlockComponent['service'];
|
||||
let std!: BlockStdScope;
|
||||
|
||||
beforeEach(async () => {
|
||||
sessionStorage.removeItem('blocksuite:prop:record');
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
edgelessRoot = getDocRootBlock(window.doc, window.editor, 'edgeless');
|
||||
service = edgelessRoot.service;
|
||||
std = edgelessRoot.std;
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('shapes', () => {
|
||||
// rect shape
|
||||
const rectId = service.addElement('shape', { shapeType: ShapeType.Rect });
|
||||
const rectShape = service.getElementById(rectId) as ShapeElementModel;
|
||||
expect(rectShape.fillColor).toBe(ShapeFillColor.Yellow);
|
||||
service.updateElement(rectId, {
|
||||
fillColor: ShapeFillColor.Orange,
|
||||
});
|
||||
expect(
|
||||
std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Rect}`]
|
||||
.fillColor
|
||||
).toBe(ShapeFillColor.Orange);
|
||||
|
||||
// diamond shape
|
||||
const diamondId = service.addElement('shape', {
|
||||
shapeType: ShapeType.Diamond,
|
||||
});
|
||||
const diamondShape = service.getElementById(diamondId) as ShapeElementModel;
|
||||
expect(diamondShape.fillColor).toBe(ShapeFillColor.Yellow);
|
||||
service.updateElement(diamondId, {
|
||||
fillColor: ShapeFillColor.Blue,
|
||||
});
|
||||
expect(
|
||||
std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Diamond}`]
|
||||
.fillColor
|
||||
).toBe(ShapeFillColor.Blue);
|
||||
|
||||
// rounded rect shape
|
||||
const roundedRectId = service.addElement('shape', {
|
||||
shapeType: ShapeType.Rect,
|
||||
radius: 0.1,
|
||||
});
|
||||
const roundedRectShape = service.getElementById(
|
||||
roundedRectId
|
||||
) as ShapeElementModel;
|
||||
expect(roundedRectShape.fillColor).toBe(ShapeFillColor.Yellow);
|
||||
service.updateElement(roundedRectId, {
|
||||
fillColor: ShapeFillColor.Green,
|
||||
});
|
||||
expect(
|
||||
std.get(EditPropsStore).lastProps$.value['shape:roundedRect'].fillColor
|
||||
).toBe(ShapeFillColor.Green);
|
||||
|
||||
// apply last props
|
||||
const rectId2 = service.addElement('shape', { shapeType: ShapeType.Rect });
|
||||
const rectShape2 = service.getElementById(rectId2) as ShapeElementModel;
|
||||
expect(rectShape2.fillColor).toBe(ShapeFillColor.Orange);
|
||||
|
||||
const diamondId2 = service.addElement('shape', {
|
||||
shapeType: ShapeType.Diamond,
|
||||
});
|
||||
const diamondShape2 = service.getElementById(
|
||||
diamondId2
|
||||
) as ShapeElementModel;
|
||||
expect(diamondShape2.fillColor).toBe(ShapeFillColor.Blue);
|
||||
|
||||
const roundedRectId2 = service.addElement('shape', {
|
||||
shapeType: ShapeType.Rect,
|
||||
radius: 0.1,
|
||||
});
|
||||
const roundedRectShape2 = service.getElementById(
|
||||
roundedRectId2
|
||||
) as ShapeElementModel;
|
||||
expect(roundedRectShape2.fillColor).toBe(ShapeFillColor.Green);
|
||||
});
|
||||
|
||||
test('connector', () => {
|
||||
const id = service.addElement('connector', { mode: 0 });
|
||||
const connector = service.getElementById(id) as ConnectorElementModel;
|
||||
expect(connector.stroke).toBe(LineColor.Grey);
|
||||
expect(connector.strokeWidth).toBe(2);
|
||||
expect(connector.strokeStyle).toBe('solid');
|
||||
expect(connector.frontEndpointStyle).toBe('None');
|
||||
expect(connector.rearEndpointStyle).toBe('Arrow');
|
||||
service.updateElement(id, { strokeWidth: 10 });
|
||||
|
||||
const id2 = service.addElement('connector', { mode: 1 });
|
||||
const connector2 = service.getElementById(id2) as ConnectorElementModel;
|
||||
expect(connector2.strokeWidth).toBe(10);
|
||||
service.updateElement(id2, {
|
||||
labelStyle: {
|
||||
color: LineColor.Magenta,
|
||||
fontFamily: FontFamily.Kalam,
|
||||
},
|
||||
});
|
||||
|
||||
const id3 = service.addElement('connector', { mode: 1 });
|
||||
const connector3 = service.getElementById(id3) as ConnectorElementModel;
|
||||
expect(connector3.strokeWidth).toBe(10);
|
||||
expect(connector3.labelStyle.color).toBe(LineColor.Magenta);
|
||||
expect(connector3.labelStyle.fontFamily).toBe(FontFamily.Kalam);
|
||||
});
|
||||
|
||||
test('brush', () => {
|
||||
const id = service.addElement('brush', {});
|
||||
const brush = service.getElementById(id) as BrushElementModel;
|
||||
expect(brush.color).toEqual({
|
||||
dark: LineColor.White,
|
||||
light: LineColor.Black,
|
||||
});
|
||||
expect(brush.lineWidth).toBe(4);
|
||||
service.updateElement(id, { lineWidth: 10 });
|
||||
const secondBrush = service.getElementById(
|
||||
service.addElement('brush', {})
|
||||
) as BrushElementModel;
|
||||
expect(secondBrush.lineWidth).toBe(10);
|
||||
});
|
||||
|
||||
test('text', () => {
|
||||
const id = service.addElement('text', {});
|
||||
const text = service.getElementById(id) as TextElementModel;
|
||||
expect(text.fontSize).toBe(24);
|
||||
service.updateElement(id, { fontSize: 36 });
|
||||
const secondText = service.getElementById(
|
||||
service.addElement('text', {})
|
||||
) as TextElementModel;
|
||||
expect(secondText.fontSize).toBe(36);
|
||||
});
|
||||
|
||||
test('mindmap', () => {
|
||||
const id = service.addElement('mindmap', {});
|
||||
const mindmap = service.getElementById(id) as MindmapElementModel;
|
||||
expect(mindmap.layoutType).toBe(LayoutType.RIGHT);
|
||||
expect(mindmap.style).toBe(MindmapStyle.ONE);
|
||||
service.updateElement(id, {
|
||||
layoutType: LayoutType.BALANCE,
|
||||
style: MindmapStyle.THREE,
|
||||
});
|
||||
|
||||
const id2 = service.addElement('mindmap', {});
|
||||
const mindmap2 = service.getElementById(id2) as MindmapElementModel;
|
||||
expect(mindmap2.layoutType).toBe(LayoutType.BALANCE);
|
||||
expect(mindmap2.style).toBe(MindmapStyle.THREE);
|
||||
});
|
||||
|
||||
test('edgeless-text', () => {
|
||||
const surface = getSurfaceBlock(doc);
|
||||
const id = service.addBlock('affine:edgeless-text', {}, surface!.id);
|
||||
const text = service.getElementById(id) as EdgelessTextBlockModel;
|
||||
expect(text.color).toBe(DEFAULT_TEXT_COLOR);
|
||||
expect(text.fontFamily).toBe(FontFamily.Inter);
|
||||
service.updateElement(id, {
|
||||
color: LineColor.Green,
|
||||
fontFamily: FontFamily.OrelegaOne,
|
||||
});
|
||||
|
||||
const id2 = service.addBlock('affine:edgeless-text', {}, surface!.id);
|
||||
const text2 = service.getElementById(id2) as EdgelessTextBlockModel;
|
||||
expect(text2.color).toBe(LineColor.Green);
|
||||
expect(text2.fontFamily).toBe(FontFamily.OrelegaOne);
|
||||
});
|
||||
|
||||
test('note', () => {
|
||||
const id = service.addBlock('affine:note', {}, doc.root!.id);
|
||||
const note = service.getElementById(id) as NoteBlockModel;
|
||||
expect(note.background).toBe(DEFAULT_NOTE_BACKGROUND_COLOR);
|
||||
expect(note.edgeless.style.shadowType).toBe(DEFAULT_NOTE_SHADOW);
|
||||
service.updateElement(id, {
|
||||
background: NoteBackgroundColor.Purple,
|
||||
edgeless: {
|
||||
style: {
|
||||
shadowType: NoteShadow.Film,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const id2 = service.addBlock('affine:note', {}, doc.root!.id);
|
||||
const note2 = service.getElementById(id2) as NoteBlockModel;
|
||||
expect(note2.background).toBe(NoteBackgroundColor.Purple);
|
||||
expect(note2.edgeless.style.shadowType).toBe(NoteShadow.Film);
|
||||
});
|
||||
|
||||
test('frame', () => {
|
||||
const surface = getSurfaceBlock(doc);
|
||||
const id = service.addBlock('affine:frame', {}, surface!.id);
|
||||
const note = service.getElementById(id) as FrameBlockModel;
|
||||
expect(note.background).toBe('--affine-palette-transparent');
|
||||
service.updateElement(id, {
|
||||
background: FrameBackgroundColor.Purple,
|
||||
});
|
||||
|
||||
const id2 = service.addBlock('affine:frame', {}, surface!.id);
|
||||
const frame2 = service.getElementById(id2) as FrameBlockModel;
|
||||
expect(frame2.background).toBe(FrameBackgroundColor.Purple);
|
||||
service.updateElement(id, {
|
||||
background: { normal: '#def4e740' },
|
||||
});
|
||||
|
||||
const id3 = service.addBlock('affine:frame', {}, surface!.id);
|
||||
const frame3 = service.getElementById(id3) as FrameBlockModel;
|
||||
expect(frame3.background).toEqual({ normal: '#def4e740' });
|
||||
service.updateElement(id, {
|
||||
background: { light: '#a381aa23', dark: '#6e907452' },
|
||||
});
|
||||
|
||||
const id4 = service.addBlock('affine:frame', {}, surface!.id);
|
||||
const frame4 = service.getElementById(id4) as FrameBlockModel;
|
||||
expect(frame4.background).toEqual({
|
||||
light: '#a381aa23',
|
||||
dark: '#6e907452',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,883 @@
|
||||
import { CommonUtils } from '@blocksuite/affine-block-surface';
|
||||
import type { BlockComponent } from '@blocksuite/block-std';
|
||||
import type {
|
||||
EdgelessRootBlockComponent,
|
||||
GroupElementModel,
|
||||
NoteBlockModel,
|
||||
} from '@blocksuite/blocks';
|
||||
import { type BlockModel, type Doc, DocCollection } from '@blocksuite/store';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { wait } from '../utils/common.js';
|
||||
import {
|
||||
addNote as _addNote,
|
||||
getDocRootBlock,
|
||||
getSurface,
|
||||
} from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
let service!: EdgelessRootBlockComponent['service'];
|
||||
|
||||
const addNote = (doc: Doc, props: Record<string, unknown> = {}) => {
|
||||
return _addNote(doc, {
|
||||
index: service.layer.generateIndex(),
|
||||
...props,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
service = getDocRootBlock(window.doc, window.editor, 'edgeless').service;
|
||||
|
||||
return async () => {
|
||||
await wait(100);
|
||||
cleanup();
|
||||
};
|
||||
});
|
||||
|
||||
test('layer manager initial state', () => {
|
||||
expect(service.layer).toBeDefined();
|
||||
expect(service.layer.layers.length).toBe(0);
|
||||
expect(service.layer.canvasLayers.length).toBe(1);
|
||||
});
|
||||
|
||||
describe('add new edgeless blocks or canvas elements should update layer automatically', () => {
|
||||
test('add note, note, shape sequentially', async () => {
|
||||
addNote(doc);
|
||||
addNote(doc);
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
|
||||
await wait();
|
||||
|
||||
expect(service.layer.layers.length).toBe(2);
|
||||
});
|
||||
|
||||
test('add note, shape, note sequentially', async () => {
|
||||
addNote(doc);
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
addNote(doc);
|
||||
await wait();
|
||||
|
||||
expect(service.layer.layers.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
test('delete element should update layer automatically', () => {
|
||||
const id = addNote(doc);
|
||||
const canvasElId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
|
||||
service.removeElement(id);
|
||||
|
||||
expect(service.layer.layers.length).toBe(1);
|
||||
|
||||
service.removeElement(canvasElId);
|
||||
|
||||
expect(service.layer.layers.length).toBe(0);
|
||||
});
|
||||
|
||||
test('change element should update layer automatically', async () => {
|
||||
const id = addNote(doc);
|
||||
const canvasElId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
|
||||
await wait();
|
||||
|
||||
service.updateElement(id, {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(id)!,
|
||||
'forward'
|
||||
),
|
||||
});
|
||||
expect(service.layer.layers[service.layer.layers.length - 1].type).toBe(
|
||||
'block'
|
||||
);
|
||||
|
||||
service.updateElement(canvasElId, {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(canvasElId)!,
|
||||
'forward'
|
||||
),
|
||||
});
|
||||
expect(service.layer.layers[service.layer.layers.length - 1].type).toBe(
|
||||
'canvas'
|
||||
);
|
||||
});
|
||||
|
||||
test('new added canvas elements should be placed in the topmost canvas layer', async () => {
|
||||
addNote(doc);
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
|
||||
await wait();
|
||||
|
||||
expect(service.layer.layers.length).toBe(2);
|
||||
expect(service.layer.layers[1].type).toBe('canvas');
|
||||
});
|
||||
|
||||
test("there should be at lease one layer in canvasLayers property even there's no canvas element", () => {
|
||||
addNote(doc);
|
||||
|
||||
expect(service.layer.canvasLayers.length).toBe(1);
|
||||
});
|
||||
|
||||
test('if the topmost layer is canvas layer, the length of canvasLayers array should equal to the counts of canvas layers', () => {
|
||||
addNote(doc);
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
addNote(doc);
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
|
||||
expect(service.layer.layers.length).toBe(4);
|
||||
expect(service.layer.canvasLayers.length).toBe(
|
||||
service.layer.layers.filter(layer => layer.type === 'canvas').length
|
||||
);
|
||||
});
|
||||
|
||||
test('a new layer should be created in canvasLayers prop when the topmost layer is not canvas layer', () => {
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
addNote(doc);
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
addNote(doc);
|
||||
|
||||
expect(service.layer.canvasLayers.length).toBe(3);
|
||||
});
|
||||
|
||||
test('layer zindex should update correctly when elements changed', async () => {
|
||||
addNote(doc);
|
||||
const noteId = addNote(doc);
|
||||
const note = service.getElementById(noteId);
|
||||
addNote(doc);
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const topShapeId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const topShape = service.getElementById(topShapeId);
|
||||
|
||||
await wait();
|
||||
|
||||
const assertInitialState = () => {
|
||||
expect(service.layer.layers[0].type).toBe('block');
|
||||
expect(service.layer.layers[0].zIndex).toBe(1);
|
||||
|
||||
expect(service.layer.layers[1].type).toBe('canvas');
|
||||
expect(service.layer.layers[1].zIndex).toBe(4);
|
||||
};
|
||||
assertInitialState();
|
||||
|
||||
service.doc.captureSync();
|
||||
|
||||
service.updateElement(noteId, {
|
||||
index: service.layer.getReorderedIndex(note!, 'front'),
|
||||
});
|
||||
await wait();
|
||||
service.doc.captureSync();
|
||||
|
||||
const assert2StepState = () => {
|
||||
expect(service.layer.layers[1].type).toBe('canvas');
|
||||
expect(service.layer.layers[1].zIndex).toBe(3);
|
||||
|
||||
expect(service.layer.layers[2].type).toBe('block');
|
||||
expect(service.layer.layers[2].zIndex).toBe(4);
|
||||
};
|
||||
assert2StepState();
|
||||
|
||||
service.updateElement(topShapeId, {
|
||||
index: service.layer.getReorderedIndex(topShape!, 'front'),
|
||||
});
|
||||
await wait();
|
||||
service.doc.captureSync();
|
||||
|
||||
expect(service.layer.layers[3].type).toBe('canvas');
|
||||
expect(service.layer.layers[3].zIndex).toBe(5);
|
||||
|
||||
service.doc.undo();
|
||||
await wait();
|
||||
assert2StepState();
|
||||
|
||||
service.doc.undo();
|
||||
await wait();
|
||||
assertInitialState();
|
||||
});
|
||||
|
||||
test('blocks should rerender when their z-index changed', async () => {
|
||||
const blocks = [addNote(doc), addNote(doc), addNote(doc), addNote(doc)];
|
||||
const assertBlocksContent = () => {
|
||||
const blocks = Array.from(
|
||||
document.querySelectorAll(
|
||||
'affine-edgeless-root gfx-viewport > [data-block-id]'
|
||||
)
|
||||
);
|
||||
|
||||
expect(blocks.length).toBe(5);
|
||||
|
||||
blocks.forEach(element => {
|
||||
expect(element.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
};
|
||||
|
||||
await wait();
|
||||
assertBlocksContent();
|
||||
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
index: CommonUtils.generateKeyBetween(
|
||||
service.getElementById(blocks[1])!.index,
|
||||
service.getElementById(blocks[2])!.index
|
||||
),
|
||||
});
|
||||
|
||||
await wait();
|
||||
assertBlocksContent();
|
||||
});
|
||||
|
||||
describe('layer reorder functionality', () => {
|
||||
let ids: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
ids = [
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
addNote(doc),
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
addNote(doc),
|
||||
];
|
||||
});
|
||||
|
||||
test('forward', async () => {
|
||||
service.updateElement(ids[0], {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(ids[0])!,
|
||||
'forward'
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[0]) as any)
|
||||
)
|
||||
).toBe(1);
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[1]) as any)
|
||||
)
|
||||
).toBe(0);
|
||||
|
||||
await wait();
|
||||
|
||||
service.updateElement(ids[1], {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(ids[1])!,
|
||||
'forward'
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[0]) as any)
|
||||
)
|
||||
).toBe(0);
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[1]) as any)
|
||||
)
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test('front', async () => {
|
||||
service.updateElement(ids[0], {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(ids[0])!,
|
||||
'front'
|
||||
),
|
||||
});
|
||||
|
||||
await wait();
|
||||
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[0]) as any)
|
||||
)
|
||||
).toBe(3);
|
||||
|
||||
service.updateElement(ids[1], {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(ids[1])!,
|
||||
'front'
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[1]) as any)
|
||||
)
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
test('backward', async () => {
|
||||
service.updateElement(ids[3], {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(ids[3])!,
|
||||
'backward'
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[3]) as any)
|
||||
)
|
||||
).toBe(1);
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[2]) as any)
|
||||
)
|
||||
).toBe(2);
|
||||
|
||||
await wait();
|
||||
|
||||
service.updateElement(ids[2], {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(ids[2])!,
|
||||
'backward'
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[3]) as any)
|
||||
)
|
||||
).toBe(3);
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[2]) as any)
|
||||
)
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test('back', async () => {
|
||||
service.updateElement(ids[3], {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(ids[3])!,
|
||||
'back'
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[3]) as any)
|
||||
)
|
||||
).toBe(0);
|
||||
|
||||
await wait();
|
||||
|
||||
service.updateElement(ids[2], {
|
||||
index: service.layer.getReorderedIndex(
|
||||
service.getElementById(ids[2])!,
|
||||
'back'
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
service.layer.layers.findIndex(layer =>
|
||||
layer.set.has(service.getElementById(ids[2]) as any)
|
||||
)
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group related functionality', () => {
|
||||
const createGroup = (
|
||||
service: EdgelessRootBlockComponent['service'],
|
||||
childIds: string[]
|
||||
) => {
|
||||
const children = new DocCollection.Y.Map<boolean>();
|
||||
childIds.forEach(id => children.set(id, true));
|
||||
|
||||
return service.addElement('group', {
|
||||
children,
|
||||
});
|
||||
};
|
||||
|
||||
test("new added group should effect it children's layer", async () => {
|
||||
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
||||
const elements = [
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
addNote(doc),
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
addNote(doc),
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
];
|
||||
|
||||
await wait(0);
|
||||
expect(
|
||||
edgeless.querySelectorAll<HTMLCanvasElement>('.indexable-canvas').length
|
||||
).toBe(2);
|
||||
|
||||
Array.from(
|
||||
edgeless.querySelectorAll<HTMLCanvasElement>('.indexable-canvas')
|
||||
).forEach(canvas => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
expect(rect.width).toBeGreaterThan(0);
|
||||
expect(rect.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
createGroup(
|
||||
service,
|
||||
elements.filter((_, idx) => idx !== 1 && idx !== 3)
|
||||
);
|
||||
|
||||
expect(service.layer.layers.length).toBe(2);
|
||||
|
||||
expect(service.layer.layers[0].type).toBe('block');
|
||||
expect(service.layer.layers[0].set.size).toBe(2);
|
||||
|
||||
expect(service.layer.layers[1].type).toBe('canvas');
|
||||
expect(service.layer.layers[1].set.size).toBe(4);
|
||||
|
||||
expect(
|
||||
edgeless.querySelectorAll<HTMLCanvasElement>('.indexable-canvas').length
|
||||
).toBe(0);
|
||||
|
||||
const topCanvas = edgeless.querySelector(
|
||||
'affine-surface canvas'
|
||||
) as HTMLCanvasElement;
|
||||
|
||||
expect(
|
||||
Number(
|
||||
(
|
||||
edgeless.querySelector(
|
||||
`[data-block-id="${elements[1]}"]`
|
||||
) as HTMLElement
|
||||
).style.zIndex
|
||||
)
|
||||
).toBeLessThan(Number(topCanvas.style.zIndex));
|
||||
expect(
|
||||
Number(
|
||||
(
|
||||
edgeless.querySelector(
|
||||
`[data-block-id="${elements[3]}"]`
|
||||
) as HTMLElement
|
||||
).style.zIndex
|
||||
)
|
||||
).toBeLessThan(Number(topCanvas.style.zIndex));
|
||||
});
|
||||
|
||||
test("change group index should update its children's layer", () => {
|
||||
const elements = [
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
addNote(doc),
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
addNote(doc),
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
];
|
||||
|
||||
const groupId = createGroup(
|
||||
service,
|
||||
elements.filter((_, idx) => idx !== 1 && idx !== 3)
|
||||
);
|
||||
const group = service.getElementById(groupId)!;
|
||||
|
||||
expect(service.layer.layers.length).toBe(2);
|
||||
|
||||
group.index = service.layer.getReorderedIndex(group, 'back');
|
||||
expect(service.layer.layers[0].type).toBe('canvas');
|
||||
expect(service.layer.layers[0].set.size).toBe(4);
|
||||
expect(service.layer.layers[0].elements[0]).toBe(group);
|
||||
|
||||
group.index = service.layer.getReorderedIndex(group, 'front');
|
||||
expect(service.layer.layers[1].type).toBe('canvas');
|
||||
expect(service.layer.layers[1].set.size).toBe(4);
|
||||
expect(service.layer.layers[1].elements[0]).toBe(group);
|
||||
});
|
||||
|
||||
test('should keep relative index order of elements after group, ungroup, undo, redo', () => {
|
||||
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
||||
const elementIds = [
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
addNote(doc),
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
addNote(doc),
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
}),
|
||||
];
|
||||
service.doc.captureSync();
|
||||
const elements = elementIds.map(id => service.getElementById(id)!);
|
||||
|
||||
const isKeptRelativeOrder = () => {
|
||||
return elements.every((element, idx) => {
|
||||
if (idx === 0) return true;
|
||||
return elements[idx - 1].index < element.index;
|
||||
});
|
||||
};
|
||||
|
||||
expect(isKeptRelativeOrder()).toBeTruthy();
|
||||
|
||||
const groupId = createGroup(edgeless.service, elementIds);
|
||||
expect(isKeptRelativeOrder()).toBeTruthy();
|
||||
|
||||
service.ungroup(service.getElementById(groupId) as GroupElementModel);
|
||||
expect(isKeptRelativeOrder()).toBeTruthy();
|
||||
|
||||
service.doc.undo();
|
||||
expect(isKeptRelativeOrder()).toBeTruthy();
|
||||
|
||||
service.doc.redo();
|
||||
expect(isKeptRelativeOrder()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compare function', () => {
|
||||
const SORT_ORDER = {
|
||||
AFTER: 1,
|
||||
BEFORE: -1,
|
||||
SAME: 0,
|
||||
};
|
||||
const createGroup = (
|
||||
service: EdgelessRootBlockComponent['service'],
|
||||
childIds: string[]
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
) => {
|
||||
const children = new DocCollection.Y.Map<boolean>();
|
||||
childIds.forEach(id => children.set(id, true));
|
||||
|
||||
return service.addElement('group', {
|
||||
children,
|
||||
});
|
||||
};
|
||||
|
||||
test('compare same element', () => {
|
||||
const shapeId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const shapeEl = service.getElementById(shapeId)!;
|
||||
expect(service.layer.compare(shapeEl, shapeEl)).toBe(SORT_ORDER.SAME);
|
||||
|
||||
const groupId = createGroup(service, [shapeId]);
|
||||
const groupEl = service.getElementById(groupId)!;
|
||||
expect(service.layer.compare(groupEl, groupEl)).toBe(SORT_ORDER.SAME);
|
||||
|
||||
const noteId = addNote(doc);
|
||||
const note = service.getElementById(noteId)! as NoteBlockModel;
|
||||
expect(service.layer.compare(note, note)).toBe(SORT_ORDER.SAME);
|
||||
});
|
||||
|
||||
test('compare a group and its child', () => {
|
||||
const shapeId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const shapeEl = service.getElementById(shapeId)!;
|
||||
const noteId = addNote(doc);
|
||||
const note = service.getElementById(noteId)! as NoteBlockModel;
|
||||
const groupId = createGroup(service, [shapeId, noteId]);
|
||||
const groupEl = service.getElementById(groupId)!;
|
||||
|
||||
expect(service.layer.compare(groupEl, shapeEl)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(shapeEl, groupEl)).toBe(SORT_ORDER.AFTER);
|
||||
expect(service.layer.compare(groupEl, note)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(note, groupEl)).toBe(SORT_ORDER.AFTER);
|
||||
});
|
||||
|
||||
test('compare two different elements', () => {
|
||||
const shape1Id = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const shape1 = service.getElementById(shape1Id)!;
|
||||
const shape2Id = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const shape2 = service.getElementById(shape2Id)!;
|
||||
const note1Id = addNote(doc);
|
||||
|
||||
const note1 = service.getElementById(note1Id)! as NoteBlockModel;
|
||||
const note2Id = addNote(doc);
|
||||
const note2 = service.getElementById(note2Id)! as NoteBlockModel;
|
||||
|
||||
expect(service.layer.compare(shape1, shape2)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(shape2, shape1)).toBe(SORT_ORDER.AFTER);
|
||||
|
||||
expect(service.layer.compare(note1, note2)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(note2, note1)).toBe(SORT_ORDER.AFTER);
|
||||
|
||||
expect(service.layer.compare(shape1, note1)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(note1, shape1)).toBe(SORT_ORDER.AFTER);
|
||||
|
||||
expect(service.layer.compare(shape2, note2)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(note2, shape2)).toBe(SORT_ORDER.AFTER);
|
||||
});
|
||||
|
||||
test('compare nested elements', () => {
|
||||
const shape1Id = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const shape2Id = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const note1Id = addNote(doc);
|
||||
const note2Id = addNote(doc);
|
||||
const group1Id = createGroup(service, [
|
||||
shape1Id,
|
||||
shape2Id,
|
||||
note1Id,
|
||||
note2Id,
|
||||
]);
|
||||
const group2Id = createGroup(service, [group1Id]);
|
||||
|
||||
const shape1 = service.getElementById(shape1Id)!;
|
||||
const shape2 = service.getElementById(shape2Id)!;
|
||||
const note1 = service.getElementById(note1Id)! as NoteBlockModel;
|
||||
const note2 = service.getElementById(note2Id)! as NoteBlockModel;
|
||||
const group1 = service.getElementById(group1Id)!;
|
||||
const group2 = service.getElementById(group2Id)!;
|
||||
|
||||
// assert nested group to group
|
||||
expect(service.layer.compare(group2, group1)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(group1, group2)).toBe(SORT_ORDER.AFTER);
|
||||
|
||||
// assert element in the same group
|
||||
expect(service.layer.compare(shape1, shape2)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(shape2, shape1)).toBe(SORT_ORDER.AFTER);
|
||||
expect(service.layer.compare(note1, note2)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(note2, note1)).toBe(SORT_ORDER.AFTER);
|
||||
|
||||
// assert group and its nested element
|
||||
expect(service.layer.compare(group2, shape1)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(shape1, group2)).toBe(SORT_ORDER.AFTER);
|
||||
expect(service.layer.compare(group1, shape2)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(shape2, group1)).toBe(SORT_ORDER.AFTER);
|
||||
expect(service.layer.compare(group2, note1)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(note1, group2)).toBe(SORT_ORDER.AFTER);
|
||||
expect(service.layer.compare(group1, note2)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(note2, group1)).toBe(SORT_ORDER.AFTER);
|
||||
});
|
||||
|
||||
test('compare two nested elements', () => {
|
||||
const groupAShapeId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const groupANoteId = addNote(doc);
|
||||
const groupAId = createGroup(service, [
|
||||
createGroup(service, [groupAShapeId, groupANoteId]),
|
||||
]);
|
||||
const groupAShape = service.getElementById(groupAShapeId)!;
|
||||
const groupANote = service.getElementById(groupANoteId)!;
|
||||
const groupA = service.getElementById(groupAId)!;
|
||||
|
||||
const groupBShapeId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const groupBNoteId = addNote(doc);
|
||||
const groupBId = createGroup(service, [
|
||||
createGroup(service, [groupBShapeId, groupBNoteId]),
|
||||
]);
|
||||
const groupBShape = service.getElementById(groupBShapeId)!;
|
||||
const groupBNote = service.getElementById(groupBNoteId)!;
|
||||
const groupB = service.getElementById(groupBId)!;
|
||||
|
||||
expect(service.layer.compare(groupAShape, groupBShape)).toBe(
|
||||
SORT_ORDER.BEFORE
|
||||
);
|
||||
expect(service.layer.compare(groupBShape, groupAShape)).toBe(
|
||||
SORT_ORDER.AFTER
|
||||
);
|
||||
expect(service.layer.compare(groupANote, groupBNote)).toBe(
|
||||
SORT_ORDER.BEFORE
|
||||
);
|
||||
expect(service.layer.compare(groupBNote, groupANote)).toBe(
|
||||
SORT_ORDER.AFTER
|
||||
);
|
||||
expect(service.layer.compare(groupB, groupA)).toBe(SORT_ORDER.AFTER);
|
||||
|
||||
groupB.index = service.layer.getReorderedIndex(groupB, 'back');
|
||||
expect(service.layer.compare(groupB, groupA)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(groupAShape, groupBShape)).toBe(
|
||||
SORT_ORDER.AFTER
|
||||
);
|
||||
expect(service.layer.compare(groupBShape, groupAShape)).toBe(
|
||||
SORT_ORDER.BEFORE
|
||||
);
|
||||
expect(service.layer.compare(groupANote, groupBNote)).toBe(
|
||||
SORT_ORDER.AFTER
|
||||
);
|
||||
expect(service.layer.compare(groupBNote, groupANote)).toBe(
|
||||
SORT_ORDER.BEFORE
|
||||
);
|
||||
|
||||
groupA.index = service.layer.getReorderedIndex(groupA, 'back');
|
||||
expect(service.layer.compare(groupA, groupB)).toBe(SORT_ORDER.BEFORE);
|
||||
expect(service.layer.compare(groupAShape, groupBShape)).toBe(
|
||||
SORT_ORDER.BEFORE
|
||||
);
|
||||
expect(service.layer.compare(groupBShape, groupAShape)).toBe(
|
||||
SORT_ORDER.AFTER
|
||||
);
|
||||
expect(service.layer.compare(groupANote, groupBNote)).toBe(
|
||||
SORT_ORDER.BEFORE
|
||||
);
|
||||
expect(service.layer.compare(groupBNote, groupANote)).toBe(
|
||||
SORT_ORDER.AFTER
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('indexed canvas should be inserted into edgeless portal when switch to edgeless mode', async () => {
|
||||
let surface = getSurface(doc, editor);
|
||||
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
|
||||
addNote(doc);
|
||||
|
||||
await wait();
|
||||
|
||||
service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
|
||||
editor.mode = 'page';
|
||||
await wait();
|
||||
editor.mode = 'edgeless';
|
||||
await wait();
|
||||
|
||||
surface = getSurface(doc, editor);
|
||||
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
||||
expect(edgeless.querySelectorAll('.indexable-canvas').length).toBe(1);
|
||||
|
||||
const indexedCanvas = edgeless.querySelectorAll(
|
||||
'.indexable-canvas'
|
||||
)[0] as HTMLCanvasElement;
|
||||
|
||||
expect(indexedCanvas.width).toBe(surface.renderer.canvas.width);
|
||||
expect(indexedCanvas.height).toBe(surface.renderer.canvas.height);
|
||||
expect(indexedCanvas.width).not.toBe(0);
|
||||
expect(indexedCanvas.height).not.toBe(0);
|
||||
});
|
||||
|
||||
test('the actual rendering z-index should satisfy the logic order of their indexes', async () => {
|
||||
editor.mode = 'page';
|
||||
|
||||
await wait();
|
||||
|
||||
const indexes = [
|
||||
'ao',
|
||||
'b0D',
|
||||
'ar',
|
||||
'as',
|
||||
'at',
|
||||
'au',
|
||||
'av',
|
||||
'b0Y',
|
||||
'b0V',
|
||||
'b0H',
|
||||
'b0M',
|
||||
'b0T',
|
||||
'b0f',
|
||||
'b0fV',
|
||||
'b0g',
|
||||
'b0i',
|
||||
'b0fl',
|
||||
];
|
||||
|
||||
indexes.forEach(index => {
|
||||
addNote(doc, {
|
||||
index,
|
||||
});
|
||||
});
|
||||
|
||||
await wait();
|
||||
|
||||
editor.mode = 'edgeless';
|
||||
await wait(500);
|
||||
|
||||
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
||||
const blocks = Array.from(
|
||||
edgeless.querySelectorAll('gfx-viewport > [data-block-id]')
|
||||
) as BlockComponent[];
|
||||
|
||||
expect(blocks.length).toBe(indexes.length + 1);
|
||||
|
||||
blocks
|
||||
.filter(block => block.flavour !== 'affine:surface')
|
||||
.forEach((block, index) => {
|
||||
if (index === blocks.length - 1) return;
|
||||
|
||||
const model = block.model as BlockModel<{ index: string }>;
|
||||
const nextModel = blocks[index + 1].model as BlockModel<{
|
||||
index: string;
|
||||
}>;
|
||||
|
||||
const zIndex = Number(block.style.zIndex);
|
||||
const nextZIndex = Number(blocks[index + 1].style.zIndex);
|
||||
|
||||
expect(model.index <= nextModel.index).equals(zIndex <= nextZIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index generator', () => {
|
||||
let preinsertedShape: BlockSuite.SurfaceElementModel;
|
||||
let preinsertedNote: NoteBlockModel;
|
||||
|
||||
beforeEach(() => {
|
||||
const shapeId = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
});
|
||||
const noteId = addNote(doc);
|
||||
|
||||
preinsertedShape = service.getElementById(
|
||||
shapeId
|
||||
)! as BlockSuite.SurfaceElementModel;
|
||||
preinsertedNote = service.getElementById(noteId)! as NoteBlockModel;
|
||||
});
|
||||
|
||||
test('generator should remember the index it generated', () => {
|
||||
const generator = service.layer.createIndexGenerator();
|
||||
|
||||
const shape1 = generator();
|
||||
const block1 = generator();
|
||||
const shape2 = generator();
|
||||
const block2 = generator();
|
||||
|
||||
expect(block2 > shape2).toBeTruthy();
|
||||
expect(shape2 > block1).toBeTruthy();
|
||||
expect(block1 > shape1).toBeTruthy();
|
||||
expect(shape1 > preinsertedNote.index).toBeTruthy();
|
||||
expect(shape1 > preinsertedShape.index).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
type EdgelessRootBlockComponent,
|
||||
type MindmapElementModel,
|
||||
type MindmapSurfaceBlock,
|
||||
MiniMindmapPreview,
|
||||
} from '@blocksuite/blocks';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { wait } from '../utils/common.js';
|
||||
import { getDocRootBlock } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
describe('mind map', () => {
|
||||
let edgeless!: EdgelessRootBlockComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
|
||||
edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
||||
|
||||
edgeless.gfx.tool.setTool('default');
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('mind map preview', async () => {
|
||||
const mindmapAnswer = `
|
||||
- Mindmap
|
||||
- Node 1
|
||||
- Node 1.1
|
||||
- Node 1.2
|
||||
- Node 2
|
||||
- Node 2.1
|
||||
- Node 2.2
|
||||
`;
|
||||
|
||||
const createPreview = (
|
||||
host: EditorHost,
|
||||
answer: string = mindmapAnswer
|
||||
) => {
|
||||
const mindmapPreview = new MiniMindmapPreview();
|
||||
|
||||
mindmapPreview.answer = answer;
|
||||
mindmapPreview.host = host;
|
||||
mindmapPreview.ctx = {
|
||||
get() {
|
||||
return {};
|
||||
},
|
||||
set() {},
|
||||
};
|
||||
|
||||
document.body.append(mindmapPreview);
|
||||
|
||||
return mindmapPreview;
|
||||
};
|
||||
|
||||
const miniMindMapPreview = createPreview(window.editor.host!);
|
||||
await wait(50);
|
||||
const miniMindMapSurface = miniMindMapPreview.renderRoot.querySelector(
|
||||
'mini-mindmap-surface-block'
|
||||
) as MindmapSurfaceBlock;
|
||||
|
||||
expect(miniMindMapSurface).not.toBeNull();
|
||||
expect(miniMindMapSurface.renderer).toBeDefined();
|
||||
expect(miniMindMapSurface.renderer?.canvas.isConnected).toBe(true);
|
||||
expect(miniMindMapSurface.renderer?.canvas.width).toBeGreaterThan(0);
|
||||
expect(miniMindMapSurface.renderer?.canvas.height).toBeGreaterThan(0);
|
||||
|
||||
expect(miniMindMapSurface.model.elementModels.length).toBe(8);
|
||||
|
||||
return () => {
|
||||
miniMindMapPreview.remove();
|
||||
};
|
||||
});
|
||||
|
||||
test('new mind map should layout automatically', async () => {
|
||||
const gfx = editor.std.get(GfxControllerIdentifier);
|
||||
const mindmapId = gfx.surface!.addElement({
|
||||
type: 'mindmap',
|
||||
children: {
|
||||
text: 'Main node',
|
||||
children: [
|
||||
{
|
||||
text: 'Child node',
|
||||
},
|
||||
{
|
||||
text: 'Second child node',
|
||||
},
|
||||
{
|
||||
text: 'Third child node',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const mindmap = gfx.getElementById(mindmapId) as MindmapElementModel;
|
||||
await wait(0);
|
||||
const [child1, child2, child3] = mindmap.tree.children;
|
||||
|
||||
expect(mindmapId).not.toBeUndefined();
|
||||
expect(mindmap.tree.children.length).toBe(3);
|
||||
expect(mindmap.tree.element.x).toBeLessThan(child1.element.x);
|
||||
expect(child1.element.x).toBe(child2.element.x);
|
||||
expect(child2.element.x).toBe(child3.element.x);
|
||||
expect(child1.element.y).toBeLessThan(child2.element.y);
|
||||
expect(child2.element.y).toBeLessThan(child3.element.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,520 @@
|
||||
import type {
|
||||
BrushElementModel,
|
||||
GroupElementModel,
|
||||
ShapeElementModel,
|
||||
SurfaceBlockModel,
|
||||
} from '@blocksuite/blocks';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { wait } from '../utils/common.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
let model: SurfaceBlockModel;
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
const models = doc.getBlockByFlavour('affine:surface') as SurfaceBlockModel[];
|
||||
|
||||
model = models[0];
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
describe('elements management', () => {
|
||||
test('addElement should work correctly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
expect(model.elementModels[0].id).toBe(id);
|
||||
});
|
||||
|
||||
test('removeElement should work correctly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
model.deleteElement(id);
|
||||
|
||||
expect(model.elementModels.length).toBe(0);
|
||||
});
|
||||
|
||||
test('updateElement should work correctly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
model.updateElement(id, { xywh: '[10,10,200,200]' });
|
||||
|
||||
expect(model.elementModels[0].xywh).toBe('[10,10,200,200]');
|
||||
});
|
||||
|
||||
test('getElementById should return element', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
expect(model.getElementById(id)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('getElementById should return null if not found', () => {
|
||||
expect(model.getElementById('not-found')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('element model', () => {
|
||||
test('default value should work correctly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
const element = model.getElementById(id)! as ShapeElementModel;
|
||||
|
||||
expect(element.index).toBe('a0');
|
||||
expect(element.strokeColor).toBe('--affine-palette-line-yellow');
|
||||
expect(element.strokeWidth).toBe(4);
|
||||
});
|
||||
|
||||
test('defined prop should not be overwritten by default value', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
strokeColor: '--affine-palette-line-black',
|
||||
});
|
||||
|
||||
const element = model.getElementById(id)! as ShapeElementModel;
|
||||
|
||||
expect(element.strokeColor).toBe('--affine-palette-line-black');
|
||||
});
|
||||
|
||||
test('assign value to model property should update ymap directly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
const element = model.getElementById(id)! as ShapeElementModel;
|
||||
|
||||
expect(element.yMap.get('strokeColor')).toBe(
|
||||
'--affine-palette-line-yellow'
|
||||
);
|
||||
|
||||
element.strokeColor = '--affine-palette-line-black';
|
||||
expect(element.yMap.get('strokeColor')).toBe('--affine-palette-line-black');
|
||||
expect(element.strokeColor).toBe('--affine-palette-line-black');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group', () => {
|
||||
test('should get group', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id2 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
const groupId = model.addElement({
|
||||
type: 'group',
|
||||
children: {
|
||||
[id]: true,
|
||||
[id2]: true,
|
||||
},
|
||||
});
|
||||
const group = model.getElementById(groupId);
|
||||
const shape = model.getElementById(id)!;
|
||||
const shape2 = model.getElementById(id2)!;
|
||||
|
||||
expect(group).not.toBe(null);
|
||||
expect(model.getGroup(id)).toBe(group);
|
||||
expect(model.getGroup(id2)).toBe(group);
|
||||
expect(shape.group).toBe(group);
|
||||
expect(shape2.group).toBe(group);
|
||||
});
|
||||
|
||||
test('should return null if children property is updated', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id2 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id3 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
const groupId = model.addElement({
|
||||
type: 'group',
|
||||
children: {
|
||||
[id]: true,
|
||||
[id2]: true,
|
||||
[id3]: true,
|
||||
},
|
||||
});
|
||||
const group = model.getElementById(groupId) as GroupElementModel;
|
||||
|
||||
model.doc.transact(() => {
|
||||
group.children.delete(id);
|
||||
group.children.delete(id2);
|
||||
});
|
||||
|
||||
expect(model.getElementById(groupId)).toBe(group);
|
||||
expect(model.getGroup(id)).toBeNull();
|
||||
expect(model.getGroup(id2)).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null if group are deleted', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id2 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
const groupId = model.addElement({
|
||||
type: 'group',
|
||||
children: {
|
||||
[id]: true,
|
||||
[id2]: true,
|
||||
},
|
||||
});
|
||||
|
||||
model.deleteElement(groupId);
|
||||
expect(model.getGroup(id)).toBeNull();
|
||||
expect(model.getGroup(id2)).toBeNull();
|
||||
});
|
||||
|
||||
test('children can be updated with a plain object', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id2 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
|
||||
const groupId = model.addElement({
|
||||
type: 'group',
|
||||
children: {
|
||||
[id]: true,
|
||||
[id2]: true,
|
||||
},
|
||||
});
|
||||
const group = model.getElementById(groupId) as GroupElementModel;
|
||||
|
||||
model.updateElement(groupId, {
|
||||
children: {
|
||||
[id]: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(group.childIds).toEqual([id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connector', () => {
|
||||
test('should get connector', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id2 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const connectorId = model.addElement({
|
||||
type: 'connector',
|
||||
source: {
|
||||
id,
|
||||
},
|
||||
target: {
|
||||
id: id2,
|
||||
},
|
||||
});
|
||||
const connector = model.getElementById(connectorId)!;
|
||||
|
||||
expect(model.getConnectors(id).map(el => el.id)).toEqual([connector.id]);
|
||||
expect(model.getConnectors(id2).map(el => el.id)).toEqual([connector.id]);
|
||||
});
|
||||
|
||||
test('multiple connectors are supported', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id2 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const connectorId = model.addElement({
|
||||
type: 'connector',
|
||||
source: {
|
||||
id,
|
||||
},
|
||||
target: {
|
||||
id: id2,
|
||||
},
|
||||
});
|
||||
const connectorId2 = model.addElement({
|
||||
type: 'connector',
|
||||
source: {
|
||||
id,
|
||||
},
|
||||
target: {
|
||||
id: id2,
|
||||
},
|
||||
});
|
||||
|
||||
const connector = model.getElementById(connectorId)!;
|
||||
const connector2 = model.getElementById(connectorId2)!;
|
||||
const connectors = [connector.id, connector2.id];
|
||||
|
||||
expect(model.getConnectors(id).map(c => c.id)).toEqual(connectors);
|
||||
expect(model.getConnectors(id2).map(c => c.id)).toEqual(connectors);
|
||||
});
|
||||
|
||||
test('should return null if connector are updated', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id2 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const connectorId = model.addElement({
|
||||
type: 'connector',
|
||||
source: {
|
||||
id,
|
||||
},
|
||||
target: {
|
||||
id: id2,
|
||||
},
|
||||
});
|
||||
|
||||
model.updateElement(connectorId, {
|
||||
source: {
|
||||
position: [0, 0],
|
||||
},
|
||||
target: {
|
||||
position: [0, 0],
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.getConnectors(id)).toEqual([]);
|
||||
expect(model.getConnectors(id2)).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return null if connector are deleted', async () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const id2 = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const connectorId = model.addElement({
|
||||
type: 'connector',
|
||||
source: {
|
||||
id,
|
||||
},
|
||||
target: {
|
||||
id: id2,
|
||||
},
|
||||
});
|
||||
|
||||
model.deleteElement(connectorId);
|
||||
|
||||
await wait();
|
||||
|
||||
expect(model.getConnectors(id)).toEqual([]);
|
||||
expect(model.getConnectors(id2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stash/pop', () => {
|
||||
test('stash and pop should work correctly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
strokeWidth: 4,
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as ShapeElementModel;
|
||||
|
||||
expect(elementModel.strokeWidth).toBe(4);
|
||||
|
||||
elementModel.stash('strokeWidth');
|
||||
elementModel.strokeWidth = 10;
|
||||
expect(elementModel.strokeWidth).toBe(10);
|
||||
expect(elementModel.yMap.get('strokeWidth')).toBe(4);
|
||||
|
||||
elementModel.pop('strokeWidth');
|
||||
expect(elementModel.strokeWidth).toBe(10);
|
||||
expect(elementModel.yMap.get('strokeWidth')).toBe(10);
|
||||
|
||||
elementModel.strokeWidth = 6;
|
||||
expect(elementModel.strokeWidth).toBe(6);
|
||||
expect(elementModel.yMap.get('strokeWidth')).toBe(6);
|
||||
});
|
||||
|
||||
test('assign stashed property should emit event', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
strokeWidth: 4,
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as ShapeElementModel;
|
||||
|
||||
elementModel.stash('strokeWidth');
|
||||
|
||||
const onchange = vi.fn();
|
||||
model.elementUpdated.once(({ id }) => onchange(id));
|
||||
|
||||
elementModel.strokeWidth = 10;
|
||||
expect(onchange).toHaveBeenCalledWith(id);
|
||||
});
|
||||
|
||||
test('stashed property should also trigger derive decorator', () => {
|
||||
const id = model.addElement({
|
||||
type: 'brush',
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
[120, 150],
|
||||
],
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as BrushElementModel;
|
||||
|
||||
elementModel.stash('points');
|
||||
elementModel.points = [
|
||||
[0, 0],
|
||||
[50, 50],
|
||||
[135, 145],
|
||||
[150, 170],
|
||||
[200, 180],
|
||||
];
|
||||
const points = elementModel.points;
|
||||
|
||||
expect(elementModel.w).toBe(200 + elementModel.lineWidth);
|
||||
expect(elementModel.h).toBe(180 + elementModel.lineWidth);
|
||||
|
||||
expect(elementModel.yMap.get('points')).not.toEqual(points);
|
||||
expect(elementModel.w).toBe(200 + elementModel.lineWidth);
|
||||
expect(elementModel.h).toBe(180 + elementModel.lineWidth);
|
||||
});
|
||||
|
||||
test('non-field property should not allow stash/pop, and should failed silently ', () => {
|
||||
const id = model.addElement({
|
||||
type: 'group',
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as GroupElementModel;
|
||||
|
||||
elementModel.stash('xywh');
|
||||
elementModel.xywh = '[10,10,200,200]';
|
||||
|
||||
expect(elementModel['_stashed'].has('xywh')).toBe(false);
|
||||
|
||||
elementModel.pop('xywh');
|
||||
|
||||
expect(elementModel['_stashed'].has('xywh')).toBe(false);
|
||||
expect(elementModel.yMap.has('xywh')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('derive decorator', () => {
|
||||
test('derived decorator should work correctly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'brush',
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
[120, 150],
|
||||
],
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as BrushElementModel;
|
||||
|
||||
expect(elementModel.w).toBe(120 + elementModel.lineWidth);
|
||||
expect(elementModel.h).toBe(150 + elementModel.lineWidth);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local decorator', () => {
|
||||
test('local decorator should work correctly', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as BrushElementModel;
|
||||
|
||||
expect(elementModel.display).toBe(true);
|
||||
|
||||
elementModel.display = false;
|
||||
expect(elementModel.display).toBe(false);
|
||||
|
||||
elementModel.opacity = 0.5;
|
||||
expect(elementModel.opacity).toBe(0.5);
|
||||
});
|
||||
|
||||
test('assign local property should emit event', () => {
|
||||
const id = model.addElement({
|
||||
type: 'shape',
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as BrushElementModel;
|
||||
|
||||
const onchange = vi.fn();
|
||||
|
||||
model.elementUpdated.once(({ id }) => onchange(id));
|
||||
elementModel.display = false;
|
||||
|
||||
expect(elementModel.display).toBe(false);
|
||||
expect(onchange).toHaveBeenCalledWith(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convert decorator', () => {
|
||||
test('convert decorator', () => {
|
||||
const id = model.addElement({
|
||||
type: 'brush',
|
||||
points: [
|
||||
[50, 25],
|
||||
[200, 200],
|
||||
[300, 300],
|
||||
],
|
||||
});
|
||||
const elementModel = model.getElementById(id)! as BrushElementModel;
|
||||
const halfLineWidth = elementModel.lineWidth / 2;
|
||||
const xOffset = 50 - halfLineWidth;
|
||||
const yOffset = 25 - halfLineWidth;
|
||||
|
||||
expect(elementModel.points).toEqual([
|
||||
[50 - xOffset, 25 - yOffset],
|
||||
[200 - xOffset, 200 - yOffset],
|
||||
[300 - xOffset, 300 - yOffset],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic property', () => {
|
||||
test('empty group should have all zero xywh', () => {
|
||||
const id = model.addElement({
|
||||
type: 'group',
|
||||
});
|
||||
const group = model.getElementById(id)! as GroupElementModel;
|
||||
|
||||
expect(group.x).toBe(0);
|
||||
expect(group.y).toBe(0);
|
||||
expect(group.w).toBe(0);
|
||||
expect(group.h).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brush', () => {
|
||||
test('same lineWidth should have same xywh', () => {
|
||||
const id = model.addElement({
|
||||
type: 'brush',
|
||||
lineWidth: 2,
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
[120, 150],
|
||||
],
|
||||
});
|
||||
const brush = model.getElementById(id) as BrushElementModel;
|
||||
const oldBrushXYWH = brush.xywh;
|
||||
|
||||
brush.lineWidth = 4;
|
||||
|
||||
expect(brush.xywh).not.toBe(oldBrushXYWH);
|
||||
|
||||
brush.lineWidth = 2;
|
||||
|
||||
expect(brush.xywh).toBe(oldBrushXYWH);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
type EdgelessRootBlockComponent,
|
||||
EdgelessRootService,
|
||||
type FrameBlockComponent,
|
||||
type SurfaceRefBlockComponent,
|
||||
} from '@blocksuite/blocks';
|
||||
import type { DocSnapshot } from '@blocksuite/store';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { wait } from '../utils/common.js';
|
||||
import { addNote, getDocRootBlock } from '../utils/edgeless.js';
|
||||
import { importFromSnapshot } from '../utils/misc.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
describe('basic', () => {
|
||||
let service: EdgelessRootBlockComponent['service'];
|
||||
let edgelessRoot: EdgelessRootBlockComponent;
|
||||
let noteAId = '';
|
||||
let noteBId = '';
|
||||
let shapeAId = '';
|
||||
let shapeBId = '';
|
||||
let frameId = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
edgelessRoot = getDocRootBlock(doc, editor, 'edgeless');
|
||||
service = edgelessRoot.service;
|
||||
|
||||
noteAId = addNote(doc, {
|
||||
index: service.generateIndex(),
|
||||
});
|
||||
shapeAId = service.addElement('shape', {
|
||||
type: 'rect',
|
||||
xywh: '[0, 0, 100, 100]',
|
||||
index: service.generateIndex(),
|
||||
});
|
||||
noteBId = addNote(doc, {
|
||||
index: service.generateIndex(),
|
||||
});
|
||||
shapeBId = service.addElement('shape', {
|
||||
type: 'rect',
|
||||
xywh: '[100, 0, 100, 100]',
|
||||
index: service.generateIndex(),
|
||||
});
|
||||
frameId = service.addBlock(
|
||||
'affine:frame',
|
||||
{
|
||||
xywh: '[0, 0, 800, 200]',
|
||||
index: service.generateIndex(),
|
||||
},
|
||||
service.surface.id
|
||||
);
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('surface-ref should be rendered in page mode', async () => {
|
||||
const surfaceRefId = doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: frameId,
|
||||
},
|
||||
noteAId
|
||||
);
|
||||
|
||||
editor.mode = 'page';
|
||||
await wait();
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
|
||||
)
|
||||
).instanceOf(Element);
|
||||
});
|
||||
|
||||
test('surface-ref should be rendered as empty surface-ref-block-edgeless component page mode', async () => {
|
||||
const surfaceRefId = doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: frameId,
|
||||
},
|
||||
noteAId
|
||||
);
|
||||
|
||||
await wait();
|
||||
|
||||
const refBlock = document.querySelector(
|
||||
`affine-edgeless-surface-ref[data-block-id="${surfaceRefId}"]`
|
||||
)! as HTMLElement;
|
||||
|
||||
expect(refBlock).instanceOf(Element);
|
||||
expect(refBlock.innerText).toBe('');
|
||||
});
|
||||
|
||||
test('content in frame should be rendered in the correct order', async () => {
|
||||
const surfaceRefId = doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: frameId,
|
||||
},
|
||||
noteAId
|
||||
);
|
||||
|
||||
editor.mode = 'page';
|
||||
await wait();
|
||||
|
||||
const surfaceRef = document.querySelector(
|
||||
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
|
||||
) as HTMLElement;
|
||||
const refBlocks = Array.from(
|
||||
surfaceRef.querySelectorAll('affine-edgeless-note')
|
||||
) as HTMLElement[];
|
||||
const stackingCanvas = Array.from(
|
||||
surfaceRef.querySelectorAll('.indexable-canvas')!
|
||||
) as HTMLCanvasElement[];
|
||||
|
||||
expect(refBlocks.length).toBe(2);
|
||||
expect(stackingCanvas.length).toBe(2);
|
||||
expect(stackingCanvas[0].style.zIndex > refBlocks[0].style.zIndex).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('content in group should be rendered in the correct order', async () => {
|
||||
const groupId = service.addElement('group', {
|
||||
children: {
|
||||
[shapeAId]: true,
|
||||
[shapeBId]: true,
|
||||
[noteAId]: true,
|
||||
[noteBId]: true,
|
||||
},
|
||||
});
|
||||
const surfaceRefId = doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: groupId,
|
||||
},
|
||||
noteAId
|
||||
);
|
||||
|
||||
editor.mode = 'page';
|
||||
await wait();
|
||||
|
||||
const surfaceRef = document.querySelector(
|
||||
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
|
||||
) as HTMLElement;
|
||||
const refBlocks = Array.from(
|
||||
surfaceRef.querySelectorAll('affine-edgeless-note')
|
||||
) as HTMLElement[];
|
||||
const stackingCanvas = Array.from(
|
||||
surfaceRef.querySelectorAll('.indexable-canvas')
|
||||
) as HTMLCanvasElement[];
|
||||
|
||||
expect(refBlocks.length).toBe(2);
|
||||
expect(stackingCanvas.length).toBe(2);
|
||||
expect(stackingCanvas[1].style.zIndex > refBlocks[0].style.zIndex).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('frame should be rendered in surface-ref viewport', async () => {
|
||||
const surfaceRefId = doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: frameId,
|
||||
},
|
||||
noteAId
|
||||
);
|
||||
|
||||
editor.mode = 'page';
|
||||
await wait();
|
||||
|
||||
const surfaceRef = document.querySelector(
|
||||
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
|
||||
) as SurfaceRefBlockComponent;
|
||||
|
||||
const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService);
|
||||
|
||||
const frame = surfaceRef.querySelector(
|
||||
'affine-frame'
|
||||
) as FrameBlockComponent;
|
||||
|
||||
expect(
|
||||
edgeless.viewport.isInViewport(frame.model.elementBound)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('group should be rendered in surface-ref viewport', async () => {
|
||||
const groupId = service.addElement('group', {
|
||||
children: {
|
||||
[shapeAId]: true,
|
||||
[shapeBId]: true,
|
||||
[noteAId]: true,
|
||||
[noteBId]: true,
|
||||
},
|
||||
});
|
||||
const surfaceRefId = doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: groupId,
|
||||
},
|
||||
noteAId
|
||||
);
|
||||
|
||||
editor.mode = 'page';
|
||||
await wait();
|
||||
|
||||
const surfaceRef = document.querySelector(
|
||||
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
|
||||
) as SurfaceRefBlockComponent;
|
||||
|
||||
const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService);
|
||||
|
||||
const group = edgeless.getElementById(groupId)!;
|
||||
|
||||
expect(edgeless.viewport.isInViewport(group.elementBound)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('viewport of surface-ref should be updated when the reference xywh updated', async () => {
|
||||
const surfaceRefId = doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: frameId,
|
||||
},
|
||||
noteAId
|
||||
);
|
||||
|
||||
editor.mode = 'page';
|
||||
await wait();
|
||||
|
||||
const surfaceRef = document.querySelector(
|
||||
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
|
||||
) as SurfaceRefBlockComponent;
|
||||
|
||||
const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService);
|
||||
|
||||
const frame = surfaceRef.querySelector(
|
||||
'affine-frame'
|
||||
) as FrameBlockComponent;
|
||||
|
||||
const oldViewport = edgeless.viewport.viewportBounds;
|
||||
|
||||
frame.model.xywh = '[100, 100, 800, 200]';
|
||||
await wait();
|
||||
|
||||
expect(edgeless.viewport.viewportBounds).not.toEqual(oldViewport);
|
||||
});
|
||||
|
||||
test('view in edgeless mode button', async () => {
|
||||
const groupId = service.addElement('group', {
|
||||
children: {
|
||||
[shapeAId]: true,
|
||||
[shapeBId]: true,
|
||||
[noteAId]: true,
|
||||
[noteBId]: true,
|
||||
},
|
||||
});
|
||||
const surfaceRefId = doc.addBlock(
|
||||
'affine:surface-ref',
|
||||
{
|
||||
reference: groupId,
|
||||
},
|
||||
noteAId
|
||||
);
|
||||
|
||||
editor.mode = 'page';
|
||||
await wait();
|
||||
|
||||
const surfaceRef = document.querySelector(
|
||||
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
|
||||
) as HTMLElement;
|
||||
|
||||
expect(surfaceRef).instanceOf(Element);
|
||||
(surfaceRef as SurfaceRefBlockComponent).viewInEdgeless();
|
||||
await wait();
|
||||
});
|
||||
});
|
||||
|
||||
import snapshot from '../snapshots/edgeless/surface-ref.spec.ts/surface-ref.json' assert { type: 'json' };
|
||||
|
||||
describe('clipboard', () => {
|
||||
test('import surface-ref snapshot should render content correctly', async () => {
|
||||
await setupEditor('page');
|
||||
|
||||
const pageRoot = getDocRootBlock(doc, editor, 'page');
|
||||
const pageRootService = pageRoot.service;
|
||||
|
||||
const newDoc = await importFromSnapshot(
|
||||
pageRootService.collection,
|
||||
snapshot as DocSnapshot
|
||||
);
|
||||
expect(newDoc).toBeTruthy();
|
||||
|
||||
editor.doc = newDoc!;
|
||||
await wait();
|
||||
|
||||
const surfaceRefs = newDoc!.getBlocksByFlavour('affine:surface-ref');
|
||||
expect(surfaceRefs).toHaveLength(2);
|
||||
|
||||
const surfaceRefBlocks = surfaceRefs.map(({ id }) =>
|
||||
editor.std.view.getBlock(id)
|
||||
) as SurfaceRefBlockComponent[];
|
||||
|
||||
expect(surfaceRefBlocks[0].querySelector('.ref-placeholder')).toBeFalsy();
|
||||
expect(surfaceRefBlocks[1].querySelector('.ref-placeholder')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
EdgelessTemplatePanel,
|
||||
type Template,
|
||||
type TemplateManager,
|
||||
} from '@blocksuite/blocks';
|
||||
import { beforeEach, expect, test } from 'vitest';
|
||||
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('extension api', async () => {
|
||||
const mockTemplate = {
|
||||
name: 'Test',
|
||||
type: 'template',
|
||||
} as Template;
|
||||
const customTemplate = {
|
||||
list: () => {
|
||||
return [mockTemplate];
|
||||
},
|
||||
categories: () => {
|
||||
return ['custom'];
|
||||
},
|
||||
search: (_, __) => {
|
||||
return [mockTemplate];
|
||||
},
|
||||
} satisfies TemplateManager;
|
||||
|
||||
EdgelessTemplatePanel.templates.extend(customTemplate);
|
||||
|
||||
const categories = await EdgelessTemplatePanel.templates.categories();
|
||||
expect(categories).toContain('custom');
|
||||
|
||||
const templates = await EdgelessTemplatePanel.templates.list('any');
|
||||
expect(templates).toContain(mockTemplate);
|
||||
|
||||
const searchTemplates = await EdgelessTemplatePanel.templates.search('any');
|
||||
expect(searchTemplates).toContain(mockTemplate);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
EdgelessRootBlockComponent,
|
||||
SurfaceBlockComponent,
|
||||
} from '@blocksuite/blocks';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { click, drag, wait } from '../utils/common.js';
|
||||
import { addNote, getDocRootBlock, getSurface } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
describe('default tool', () => {
|
||||
let surface!: SurfaceBlockComponent;
|
||||
let edgeless!: EdgelessRootBlockComponent;
|
||||
let service!: EdgelessRootBlockComponent['service'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless');
|
||||
|
||||
edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
||||
surface = getSurface(window.doc, window.editor);
|
||||
service = edgeless.service;
|
||||
|
||||
edgeless.gfx.tool.setTool('default');
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
test('element click selection', async () => {
|
||||
const id = service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
xywh: '[0,0,100,100]',
|
||||
fillColor: 'red',
|
||||
});
|
||||
|
||||
await wait();
|
||||
|
||||
service.viewport.setViewport(1, [
|
||||
service.viewport.width / 2,
|
||||
service.viewport.height / 2,
|
||||
]);
|
||||
|
||||
click(edgeless.host, { x: 0, y: 50 });
|
||||
|
||||
expect(edgeless.service.selection.surfaceSelections[0].elements).toEqual([
|
||||
id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('element drag moving', async () => {
|
||||
const id = edgeless.service.addElement('shape', {
|
||||
shapeType: 'rect',
|
||||
xywh: '[0,0,100,100]',
|
||||
fillColor: 'red',
|
||||
});
|
||||
await wait();
|
||||
|
||||
edgeless.service.viewport.setViewport(1, [
|
||||
edgeless.service.viewport.width / 2,
|
||||
edgeless.service.viewport.height / 2,
|
||||
]);
|
||||
await wait();
|
||||
|
||||
click(edgeless.host, { x: 0, y: 50 });
|
||||
drag(edgeless.host, { x: 0, y: 50 }, { x: 0, y: 150 });
|
||||
await wait();
|
||||
|
||||
const element = service.getElementById(id)!;
|
||||
expect(element.xywh).toEqual(`[0,100,100,100]`);
|
||||
});
|
||||
|
||||
test('block drag moving', async () => {
|
||||
const noteId = addNote(doc);
|
||||
|
||||
await wait();
|
||||
|
||||
edgeless.service.viewport.setViewport(1, [
|
||||
surface.renderer.viewport.width / 2,
|
||||
surface.renderer.viewport.height / 2,
|
||||
]);
|
||||
await wait();
|
||||
|
||||
click(edgeless.host, { x: 50, y: 50 });
|
||||
expect(edgeless.service.selection.surfaceSelections[0].elements).toEqual([
|
||||
noteId,
|
||||
]);
|
||||
drag(edgeless.host, { x: 50, y: 50 }, { x: 150, y: 150 });
|
||||
await wait();
|
||||
|
||||
const element = service.getElementById(noteId)!;
|
||||
const [x, y] = JSON.parse(element.xywh);
|
||||
|
||||
expect(x).toEqual(100);
|
||||
expect(y).toEqual(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Doc, DocCollection, Job } from '@blocksuite/store';
|
||||
|
||||
import type { AffineEditorContainer } from '../index.js';
|
||||
|
||||
declare global {
|
||||
const editor: AffineEditorContainer;
|
||||
const doc: Doc;
|
||||
const collection: DocCollection;
|
||||
const job: Job;
|
||||
interface Window {
|
||||
editor: AffineEditorContainer;
|
||||
doc: Doc;
|
||||
job: Job;
|
||||
collection: DocCollection;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,101 @@
|
||||
import type { SurfaceBlockModel } from '@blocksuite/blocks';
|
||||
import type { PointLocation } from '@blocksuite/global/utils';
|
||||
import { beforeEach, expect, test } from 'vitest';
|
||||
|
||||
import { wait } from '../utils/common.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
const excludes = new Set([
|
||||
'shape-textBound',
|
||||
'externalXYWH',
|
||||
'connector-text',
|
||||
'connector-labelXYWH',
|
||||
]);
|
||||
|
||||
const fieldChecker: Record<string, (value: any) => boolean> = {
|
||||
'connector-path': (value: PointLocation[]) => {
|
||||
return value.length > 0;
|
||||
},
|
||||
xywh: (value: string) => {
|
||||
return value.match(xywhPattern) !== null;
|
||||
},
|
||||
};
|
||||
|
||||
const snapshotTest = async (snapshotUrl: string, elementsCount: number) => {
|
||||
const pageService = window.editor.host!.std.getService('affine:page');
|
||||
if (!pageService) {
|
||||
throw new Error('page service not found');
|
||||
}
|
||||
const transformer = pageService.transformers.zip;
|
||||
|
||||
const snapshotFile = await fetch(snapshotUrl)
|
||||
.then(res => res.blob())
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
throw e;
|
||||
});
|
||||
const [newDoc] = await transformer.importDocs(
|
||||
window.editor.doc.collection,
|
||||
snapshotFile
|
||||
);
|
||||
|
||||
if (!newDoc) {
|
||||
throw new Error('Failed to import snapshot');
|
||||
}
|
||||
|
||||
editor.doc = newDoc;
|
||||
await wait();
|
||||
|
||||
const surface = newDoc.getBlockByFlavour(
|
||||
'affine:surface'
|
||||
)[0] as SurfaceBlockModel;
|
||||
const surfaceElements = [...surface['_elementModels']].map(
|
||||
([_, { model }]) => model
|
||||
);
|
||||
|
||||
expect(surfaceElements.length).toBe(elementsCount);
|
||||
|
||||
surfaceElements.forEach(element => {
|
||||
const type = element.type;
|
||||
|
||||
for (const field in element) {
|
||||
const value = element[field as keyof typeof element];
|
||||
const typeField = `${type}-${field}`;
|
||||
|
||||
if (excludes.has(`${type}-${field}`) || excludes.has(field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldChecker[typeField] || fieldChecker[field]) {
|
||||
const checker = fieldChecker[typeField] || fieldChecker[field];
|
||||
expect(checker(value)).toBe(true);
|
||||
} else {
|
||||
expect(
|
||||
value,
|
||||
`type: ${element.type} field: "${field}"`
|
||||
).not.toBeUndefined();
|
||||
expect(value, `type: ${element.type} field: "${field}"`).not.toBeNull();
|
||||
expect(value, `type: ${element.type} field: "${field}"`).not.toBeNaN();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('page');
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
const xywhPattern = /\[(\s*-?\d+(\.\d+)?\s*,){3}(\s*-?\d+(\.\d+)?\s*)\]/;
|
||||
|
||||
test('snapshot 1 importing', async () => {
|
||||
await snapshotTest('https://test.affineassets.com/test-snapshot-1.zip', 25);
|
||||
});
|
||||
|
||||
test('snapshot 2 importing', async () => {
|
||||
await snapshotTest(
|
||||
'https://test.affineassets.com/test-snapshot-2%20(onboarding).zip',
|
||||
174
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"type": "page",
|
||||
"meta": {
|
||||
"id": "doc:home",
|
||||
"title": "",
|
||||
"createDate": 1725961648308,
|
||||
"tags": []
|
||||
},
|
||||
"blocks": {
|
||||
"type": "block",
|
||||
"id": "xguBwzpkGD",
|
||||
"flavour": "affine:page",
|
||||
"version": 2,
|
||||
"props": {
|
||||
"title": {
|
||||
"$blocksuite:internal:text$": true,
|
||||
"delta": []
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "block",
|
||||
"id": "m9-hJeL979",
|
||||
"flavour": "affine:surface",
|
||||
"version": 5,
|
||||
"props": {
|
||||
"elements": {
|
||||
"UrT0kJjtKN": {
|
||||
"index": "a2",
|
||||
"seed": 1855830969,
|
||||
"color": "--affine-palette-line-black",
|
||||
"fillColor": "--affine-palette-shape-yellow",
|
||||
"filled": true,
|
||||
"fontFamily": "blocksuite:surface:Inter",
|
||||
"fontSize": 20,
|
||||
"fontStyle": "normal",
|
||||
"fontWeight": "400",
|
||||
"maxWidth": false,
|
||||
"padding": [10, 20],
|
||||
"radius": 0,
|
||||
"rotate": 0,
|
||||
"roughness": 1.4,
|
||||
"shadow": null,
|
||||
"shapeStyle": "General",
|
||||
"shapeType": "triangle",
|
||||
"strokeColor": "--affine-palette-line-yellow",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"textAlign": "center",
|
||||
"textResizing": 1,
|
||||
"xywh": "[220.12515258789062,240.90625,99.82000732421875,80.5]",
|
||||
"type": "shape",
|
||||
"id": "UrT0kJjtKN"
|
||||
},
|
||||
"0AlG3AohCi": {
|
||||
"index": "a3",
|
||||
"seed": 1305411005,
|
||||
"color": "--affine-palette-line-black",
|
||||
"fillColor": "--affine-palette-shape-yellow",
|
||||
"filled": true,
|
||||
"fontFamily": "blocksuite:surface:Inter",
|
||||
"fontSize": 20,
|
||||
"fontStyle": "normal",
|
||||
"fontWeight": "400",
|
||||
"maxWidth": false,
|
||||
"padding": [10, 20],
|
||||
"radius": 0,
|
||||
"rotate": 0,
|
||||
"roughness": 1.4,
|
||||
"shadow": null,
|
||||
"shapeStyle": "General",
|
||||
"shapeType": "ellipse",
|
||||
"strokeColor": "--affine-palette-line-yellow",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"textAlign": "center",
|
||||
"textResizing": 1,
|
||||
"xywh": "[485.93280029296875,231.14300537109375,99.84002685546875,99.84002685546875]",
|
||||
"type": "shape",
|
||||
"id": "0AlG3AohCi"
|
||||
},
|
||||
"8fUiHCCqfG": {
|
||||
"index": "a4",
|
||||
"seed": 513823325,
|
||||
"color": "--affine-palette-line-black",
|
||||
"fillColor": "--affine-palette-shape-yellow",
|
||||
"filled": true,
|
||||
"fontFamily": "blocksuite:surface:Inter",
|
||||
"fontSize": 20,
|
||||
"fontStyle": "normal",
|
||||
"fontWeight": "400",
|
||||
"maxWidth": false,
|
||||
"padding": [10, 20],
|
||||
"radius": 0.1,
|
||||
"rotate": 0,
|
||||
"roughness": 1.4,
|
||||
"shadow": null,
|
||||
"shapeStyle": "General",
|
||||
"shapeType": "rect",
|
||||
"strokeColor": "--affine-palette-line-yellow",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"textAlign": "center",
|
||||
"textResizing": 1,
|
||||
"xywh": "[620.0800170898438,234.9613037109375,99.8399658203125,99.8399658203125]",
|
||||
"type": "shape",
|
||||
"id": "8fUiHCCqfG"
|
||||
},
|
||||
"KfzvfxYw5C": {
|
||||
"index": "a5",
|
||||
"seed": 1817567358,
|
||||
"children": {
|
||||
"affine:surface:ymap": true,
|
||||
"json": {
|
||||
"8fUiHCCqfG": true,
|
||||
"0AlG3AohCi": true
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"affine:surface:text": true,
|
||||
"delta": [
|
||||
{
|
||||
"insert": "Group 1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "group",
|
||||
"id": "KfzvfxYw5C"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "block",
|
||||
"id": "yBzeQ6raOv",
|
||||
"flavour": "affine:frame",
|
||||
"version": 1,
|
||||
"props": {
|
||||
"title": {
|
||||
"$blocksuite:internal:text$": true,
|
||||
"delta": [
|
||||
{
|
||||
"insert": "Frame 1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"background": "--affine-palette-transparent",
|
||||
"xywh": "[140.703125,199.1015625,258.6640625,164.109375]",
|
||||
"index": "a1",
|
||||
"childElementIds": {
|
||||
"UrT0kJjtKN": true
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"id": "eWPFqL6VST",
|
||||
"flavour": "affine:note",
|
||||
"version": 1,
|
||||
"props": {
|
||||
"xywh": "[0,0,498,92]",
|
||||
"background": "--affine-note-background-white",
|
||||
"index": "a0",
|
||||
"hidden": false,
|
||||
"displayMode": "both",
|
||||
"edgeless": {
|
||||
"style": {
|
||||
"borderRadius": 8,
|
||||
"borderSize": 4,
|
||||
"borderStyle": "none",
|
||||
"shadowType": "--affine-note-shadow-box"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "block",
|
||||
"id": "09MoQfa5ex",
|
||||
"flavour": "affine:paragraph",
|
||||
"version": 1,
|
||||
"props": {
|
||||
"type": "text",
|
||||
"text": {
|
||||
"$blocksuite:internal:text$": true,
|
||||
"delta": []
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"id": "NhoAqBBMZg",
|
||||
"flavour": "affine:surface-ref",
|
||||
"version": 1,
|
||||
"props": {
|
||||
"reference": "yBzeQ6raOv",
|
||||
"caption": "",
|
||||
"refFlavour": "affine:frame"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"id": "jJDE1rYIYJ",
|
||||
"flavour": "affine:surface-ref",
|
||||
"version": 1,
|
||||
"props": {
|
||||
"reference": "KfzvfxYw5C",
|
||||
"caption": "",
|
||||
"refFlavour": "group"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Point } from '@blocksuite/global/utils';
|
||||
|
||||
export function wait(time: number = 0) {
|
||||
return new Promise(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(resolve, time);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* simulate click event
|
||||
* @param target
|
||||
* @param position position relative to the target
|
||||
*/
|
||||
export function click(target: HTMLElement, position: { x: number; y: number }) {
|
||||
const element = target.getBoundingClientRect();
|
||||
const clientX = element.x + position.x;
|
||||
const clientY = element.y + position.y;
|
||||
|
||||
target.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
clientX,
|
||||
clientY,
|
||||
bubbles: true,
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX,
|
||||
clientY,
|
||||
bubbles: true,
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new MouseEvent('click', {
|
||||
clientX,
|
||||
clientY,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
type PointerOptions = {
|
||||
isPrimary?: boolean;
|
||||
pointerId?: number;
|
||||
pointerType?: string;
|
||||
};
|
||||
|
||||
const defaultPointerOptions: PointerOptions = {
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
};
|
||||
|
||||
/**
|
||||
* simulate pointerdown event
|
||||
* @param target
|
||||
* @param position position relative to the target
|
||||
*/
|
||||
export function pointerdown(
|
||||
target: HTMLElement,
|
||||
position: { x: number; y: number },
|
||||
options: PointerOptions = defaultPointerOptions
|
||||
) {
|
||||
const element = target.getBoundingClientRect();
|
||||
const clientX = element.x + position.x;
|
||||
const clientY = element.y + position.y;
|
||||
|
||||
target.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
clientX,
|
||||
clientY,
|
||||
bubbles: true,
|
||||
...options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* simulate pointerup event
|
||||
* @param target
|
||||
* @param position position relative to the target
|
||||
*/
|
||||
export function pointerup(
|
||||
target: HTMLElement,
|
||||
position: { x: number; y: number },
|
||||
options: PointerOptions = defaultPointerOptions
|
||||
) {
|
||||
const element = target.getBoundingClientRect();
|
||||
const clientX = element.x + position.x;
|
||||
const clientY = element.y + position.y;
|
||||
|
||||
target.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX,
|
||||
clientY,
|
||||
bubbles: true,
|
||||
...options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* simulate pointermove event
|
||||
* @param target
|
||||
* @param position position relative to the target
|
||||
*/
|
||||
export function pointermove(
|
||||
target: HTMLElement,
|
||||
position: { x: number; y: number },
|
||||
options: PointerOptions = defaultPointerOptions
|
||||
) {
|
||||
const element = target.getBoundingClientRect();
|
||||
const clientX = element.x + position.x;
|
||||
const clientY = element.y + position.y;
|
||||
|
||||
target.dispatchEvent(
|
||||
new PointerEvent('pointermove', {
|
||||
clientX,
|
||||
clientY,
|
||||
bubbles: true,
|
||||
...options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function drag(
|
||||
target: HTMLElement,
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number },
|
||||
step: number = 5
|
||||
) {
|
||||
pointerdown(target, start);
|
||||
pointermove(target, start);
|
||||
|
||||
if (step !== 0) {
|
||||
const xStep = (end.x - start.x) / step;
|
||||
const yStep = (end.y - start.y) / step;
|
||||
|
||||
for (const [i] of Array.from({ length: step }).entries()) {
|
||||
pointermove(target, {
|
||||
x: start.x + xStep * (i + 1),
|
||||
y: start.y + yStep * (i + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pointermove(target, end);
|
||||
pointerup(target, end);
|
||||
}
|
||||
|
||||
export function multiTouchDown(target: Element, points: Point[]) {
|
||||
points.forEach((point, index) => {
|
||||
pointerdown(target as HTMLElement, point, {
|
||||
isPrimary: index === 0,
|
||||
pointerId: index,
|
||||
pointerType: 'touch',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function multiTouchMove(
|
||||
target: Element,
|
||||
from: Point[],
|
||||
to: Point[],
|
||||
step = 5
|
||||
) {
|
||||
if (from.length !== to.length) {
|
||||
throw new Error('from and to should have the same length');
|
||||
}
|
||||
|
||||
if (step !== 0) {
|
||||
for (const [i] of Array.from({ length: step }).entries()) {
|
||||
const stepPoints = from.map((point, index) => ({
|
||||
x: point.x + ((to[index].x - point.x) / step) * (i + 1),
|
||||
y: point.y + ((to[index].y - point.y) / step) * (i + 1),
|
||||
}));
|
||||
from.forEach((_, index) => {
|
||||
pointermove(target as HTMLElement, stepPoints[index], {
|
||||
isPrimary: index === 0,
|
||||
pointerId: index,
|
||||
pointerType: 'touch',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function multiTouchUp(target: Element, points: Point[]) {
|
||||
points.forEach((point, index) => {
|
||||
pointerup(target as HTMLElement, point, {
|
||||
isPrimary: index === 0,
|
||||
pointerId: index,
|
||||
pointerType: 'touch',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import type {
|
||||
EdgelessRootBlockComponent,
|
||||
PageRootBlockComponent,
|
||||
SurfaceBlockComponent,
|
||||
} from '@blocksuite/blocks';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
|
||||
import type { AffineEditorContainer } from '../../index.js';
|
||||
|
||||
export function getSurface(doc: Doc, editor: AffineEditorContainer) {
|
||||
const surfaceModel = doc.getBlockByFlavour('affine:surface');
|
||||
|
||||
return editor.host!.view.getBlock(
|
||||
surfaceModel[0]!.id
|
||||
) as SurfaceBlockComponent;
|
||||
}
|
||||
|
||||
export function getDocRootBlock(
|
||||
doc: Doc,
|
||||
editor: AffineEditorContainer,
|
||||
mode: 'page'
|
||||
): PageRootBlockComponent;
|
||||
export function getDocRootBlock(
|
||||
doc: Doc,
|
||||
editor: AffineEditorContainer,
|
||||
mode: 'edgeless'
|
||||
): EdgelessRootBlockComponent;
|
||||
export function getDocRootBlock(
|
||||
doc: Doc,
|
||||
editor: AffineEditorContainer,
|
||||
_?: 'edgeless' | 'page'
|
||||
) {
|
||||
return editor.host!.view.getBlock(doc.root!.id) as
|
||||
| EdgelessRootBlockComponent
|
||||
| PageRootBlockComponent;
|
||||
}
|
||||
|
||||
export function addNote(doc: Doc, props: Record<string, any> = {}) {
|
||||
const noteId = doc.addBlock(
|
||||
'affine:note',
|
||||
{
|
||||
xywh: '[0, 0, 800, 100]',
|
||||
...props,
|
||||
},
|
||||
doc.root
|
||||
);
|
||||
|
||||
doc.addBlock('affine:paragraph', {}, noteId);
|
||||
|
||||
return noteId;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { replaceIdMiddleware } from '@blocksuite/blocks';
|
||||
import { type DocCollection, type DocSnapshot, Job } from '@blocksuite/store';
|
||||
|
||||
export async function importFromSnapshot(
|
||||
collection: DocCollection,
|
||||
snapshot: DocSnapshot
|
||||
) {
|
||||
const job = new Job({
|
||||
collection,
|
||||
middlewares: [replaceIdMiddleware],
|
||||
});
|
||||
|
||||
return job.snapshotToDoc(snapshot);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { effects as blocksEffects } from '@blocksuite/blocks/effects';
|
||||
import type { BlockCollection } from '@blocksuite/store';
|
||||
|
||||
import { effects } from '../../effects.js';
|
||||
|
||||
blocksEffects();
|
||||
effects();
|
||||
|
||||
import {
|
||||
CommunityCanvasTextFonts,
|
||||
type DocMode,
|
||||
FontConfigExtension,
|
||||
} from '@blocksuite/blocks';
|
||||
import { AffineSchemas } from '@blocksuite/blocks/schemas';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
DocCollection,
|
||||
IdGeneratorType,
|
||||
Schema,
|
||||
Text,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
import { AffineEditorContainer } from '../../index.js';
|
||||
|
||||
function createCollectionOptions() {
|
||||
const schema = new Schema();
|
||||
const room = Math.random().toString(16).slice(2, 8);
|
||||
|
||||
schema.register(AffineSchemas);
|
||||
|
||||
const idGenerator: IdGeneratorType = IdGeneratorType.AutoIncrement; // works only in single user mode
|
||||
|
||||
return {
|
||||
id: room,
|
||||
schema,
|
||||
idGenerator,
|
||||
defaultFlags: {
|
||||
enable_synced_doc_block: true,
|
||||
enable_pie_menu: true,
|
||||
readonly: {
|
||||
'doc:home': false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function initCollection(collection: DocCollection) {
|
||||
const doc = collection.createDoc({ id: 'doc:home' });
|
||||
|
||||
doc.load(() => {
|
||||
const rootId = doc.addBlock('affine:page', {
|
||||
title: new Text(),
|
||||
});
|
||||
doc.addBlock('affine:surface', {}, rootId);
|
||||
});
|
||||
doc.resetHistory();
|
||||
}
|
||||
|
||||
async function createEditor(collection: DocCollection, mode: DocMode = 'page') {
|
||||
const app = document.createElement('div');
|
||||
const blockCollection = collection.docs.values().next().value as
|
||||
| BlockCollection
|
||||
| undefined;
|
||||
assertExists(blockCollection, 'Need to create a doc first');
|
||||
const doc = blockCollection.getDoc();
|
||||
const editor = new AffineEditorContainer();
|
||||
editor.doc = doc;
|
||||
editor.mode = mode;
|
||||
editor.pageSpecs = editor.pageSpecs.concat([
|
||||
FontConfigExtension(CommunityCanvasTextFonts),
|
||||
]);
|
||||
editor.edgelessSpecs = editor.edgelessSpecs.concat([
|
||||
FontConfigExtension(CommunityCanvasTextFonts),
|
||||
]);
|
||||
app.append(editor);
|
||||
|
||||
window.editor = editor;
|
||||
window.doc = doc;
|
||||
|
||||
app.style.width = '100%';
|
||||
app.style.height = '1280px';
|
||||
|
||||
document.body.append(app);
|
||||
await editor.updateComplete;
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function setupEditor(mode: DocMode = 'page') {
|
||||
const collection = new DocCollection(createCollectionOptions());
|
||||
collection.meta.initialize();
|
||||
|
||||
window.collection = collection;
|
||||
|
||||
initCollection(collection);
|
||||
const appElement = await createEditor(collection, mode);
|
||||
|
||||
return () => {
|
||||
appElement.remove();
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
window.editor.remove();
|
||||
|
||||
delete (window as any).collection;
|
||||
|
||||
delete (window as any).editor;
|
||||
|
||||
delete (window as any).doc;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { EdgelessEditorBlockSpecs, ThemeProvider } from '@blocksuite/blocks';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import { css, html, nothing, type TemplateResult } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
|
||||
export class EdgelessEditor extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
edgeless-editor {
|
||||
font-family: var(--affine-font-family);
|
||||
background: var(--affine-background-primary-color);
|
||||
}
|
||||
|
||||
edgeless-editor * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media print {
|
||||
edgeless-editor {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-edgeless-viewport {
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
container-name: viewport;
|
||||
container-type: inline-size;
|
||||
}
|
||||
`;
|
||||
|
||||
get host() {
|
||||
try {
|
||||
return this.std.host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._disposables.add(
|
||||
this.doc.slots.rootAdded.on(() => this.requestUpdate())
|
||||
);
|
||||
this.std = new BlockStdScope({
|
||||
doc: this.doc,
|
||||
extensions: this.specs,
|
||||
});
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.host?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.doc.root) return nothing;
|
||||
|
||||
const std = this.std;
|
||||
const theme = std.get(ThemeProvider).edgeless$.value;
|
||||
return html`
|
||||
<div class="affine-edgeless-viewport" data-theme=${theme}>
|
||||
${guard([std], () => std.render())}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override willUpdate(
|
||||
changedProperties: Map<string | number | symbol, unknown>
|
||||
) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has('doc')) {
|
||||
this.std = new BlockStdScope({
|
||||
doc: this.doc,
|
||||
extensions: this.specs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Doc;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: TemplateResult;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor specs = EdgelessEditorBlockSpecs;
|
||||
|
||||
@state()
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-editor': EdgelessEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
BlockStdScope,
|
||||
type ExtensionType,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
type AbstractEditor,
|
||||
type DocMode,
|
||||
EdgelessEditorBlockSpecs,
|
||||
PageEditorBlockSpecs,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/blocks';
|
||||
import { SignalWatcher, Slot, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { BlockModel, Doc } from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
export class AffineEditorContainer
|
||||
extends SignalWatcher(WithDisposable(ShadowlessElement))
|
||||
implements AbstractEditor
|
||||
{
|
||||
static override styles = css`
|
||||
.affine-page-viewport {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
container-name: viewport;
|
||||
container-type: inline-size;
|
||||
font-family: var(--affine-font-family);
|
||||
}
|
||||
.affine-page-viewport * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.affine-page-viewport {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.playground-page-editor-container {
|
||||
flex-grow: 1;
|
||||
font-family: var(--affine-font-family);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playground-page-editor-container * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.playground-page-editor-container {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.edgeless-editor-container {
|
||||
font-family: var(--affine-font-family);
|
||||
background: var(--affine-background-primary-color);
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.edgeless-editor-container * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.edgeless-editor-container {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-edgeless-viewport {
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
container-name: viewport;
|
||||
container-type: inline-size;
|
||||
}
|
||||
`;
|
||||
|
||||
private _doc = signal<Doc>();
|
||||
|
||||
private _edgelessSpecs = signal<ExtensionType[]>(EdgelessEditorBlockSpecs);
|
||||
|
||||
private _mode = signal<DocMode>('page');
|
||||
|
||||
private _pageSpecs = signal<ExtensionType[]>(PageEditorBlockSpecs);
|
||||
|
||||
private _specs = computed(() =>
|
||||
this._mode.value === 'page'
|
||||
? this._pageSpecs.value
|
||||
: this._edgelessSpecs.value
|
||||
);
|
||||
|
||||
private _std = computed(() => {
|
||||
return new BlockStdScope({
|
||||
doc: this.doc,
|
||||
extensions: this._specs.value,
|
||||
});
|
||||
});
|
||||
|
||||
private _editorTemplate = computed(() => {
|
||||
return this._std.value.render();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated need to refactor
|
||||
*/
|
||||
slots: AbstractEditor['slots'] = {
|
||||
docUpdated: new Slot(),
|
||||
};
|
||||
|
||||
get doc() {
|
||||
return this._doc.value as Doc;
|
||||
}
|
||||
|
||||
set doc(doc: Doc) {
|
||||
this._doc.value = doc;
|
||||
}
|
||||
|
||||
set edgelessSpecs(specs: ExtensionType[]) {
|
||||
this._edgelessSpecs.value = specs;
|
||||
}
|
||||
|
||||
get edgelessSpecs() {
|
||||
return this._edgelessSpecs.value;
|
||||
}
|
||||
|
||||
get host() {
|
||||
try {
|
||||
return this.std.host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this._mode.value;
|
||||
}
|
||||
|
||||
set mode(mode: DocMode) {
|
||||
this._mode.value = mode;
|
||||
}
|
||||
|
||||
set pageSpecs(specs: ExtensionType[]) {
|
||||
this._pageSpecs.value = specs;
|
||||
}
|
||||
|
||||
get pageSpecs() {
|
||||
return this._pageSpecs.value;
|
||||
}
|
||||
|
||||
get rootModel() {
|
||||
return this.doc.root as BlockModel;
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this._std.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated need to refactor
|
||||
*/
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._disposables.add(
|
||||
this.doc.slots.rootAdded.on(() => this.requestUpdate())
|
||||
);
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
if (this.mode === 'page') {
|
||||
setTimeout(() => {
|
||||
if (this.autofocus) {
|
||||
const richText = this.querySelector('rich-text');
|
||||
const inlineEditor = richText?.inlineEditor;
|
||||
inlineEditor?.focusEnd();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const mode = this._mode.value;
|
||||
const themeService = this.std.get(ThemeProvider);
|
||||
const appTheme = themeService.app$.value;
|
||||
const edgelessTheme = themeService.edgeless$.value;
|
||||
|
||||
return html`${keyed(
|
||||
this.rootModel.id + mode,
|
||||
html`
|
||||
<div
|
||||
data-theme=${mode === 'page' ? appTheme : edgelessTheme}
|
||||
class=${mode === 'page'
|
||||
? 'affine-page-viewport'
|
||||
: 'affine-edgeless-viewport'}
|
||||
>
|
||||
${when(
|
||||
mode === 'page',
|
||||
() => html` <doc-title .doc=${this.doc}></doc-title> `
|
||||
)}
|
||||
<div
|
||||
class=${mode === 'page'
|
||||
? 'page-editor playground-page-editor-container'
|
||||
: 'edgeless-editor-container'}
|
||||
>
|
||||
${this._editorTemplate.value}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}`;
|
||||
}
|
||||
|
||||
switchEditor(mode: DocMode) {
|
||||
this._mode.value = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated need to refactor
|
||||
*/
|
||||
override updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has('doc')) {
|
||||
this.slots.docUpdated.emit({ newDocId: this.doc.id });
|
||||
}
|
||||
|
||||
if (!changedProperties.has('doc') && !changedProperties.has('mode')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
override accessor autofocus = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-editor-container': AffineEditorContainer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './edgeless-editor.js';
|
||||
export * from './editor-container.js';
|
||||
export * from './page-editor.js';
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
BlockStdScope,
|
||||
EditorHost,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { PageEditorBlockSpecs, ThemeProvider } from '@blocksuite/blocks';
|
||||
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
|
||||
noop(EditorHost);
|
||||
|
||||
export class PageEditor extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
page-editor {
|
||||
font-family: var(--affine-font-family);
|
||||
background: var(--affine-background-primary-color);
|
||||
}
|
||||
|
||||
page-editor * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media print {
|
||||
page-editor {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-page-viewport {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
container-name: viewport;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.page-editor-container {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
get host() {
|
||||
try {
|
||||
return this.std.host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._disposables.add(
|
||||
this.doc.slots.rootAdded.on(() => this.requestUpdate())
|
||||
);
|
||||
this.std = new BlockStdScope({
|
||||
doc: this.doc,
|
||||
extensions: this.specs,
|
||||
});
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.host?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.doc.root) return nothing;
|
||||
|
||||
const std = this.std;
|
||||
const theme = std.get(ThemeProvider).app$.value;
|
||||
return html`
|
||||
<div
|
||||
data-theme=${theme}
|
||||
class=${this.hasViewport
|
||||
? 'affine-page-viewport'
|
||||
: 'page-editor-container'}
|
||||
>
|
||||
${guard([std], () => std.render())}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override willUpdate(
|
||||
changedProperties: Map<string | number | symbol, unknown>
|
||||
) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has('doc')) {
|
||||
this.std = new BlockStdScope({
|
||||
doc: this.doc,
|
||||
extensions: this.specs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Doc;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor hasViewport = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor specs = PageEditorBlockSpecs;
|
||||
|
||||
@state()
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'page-editor': PageEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import '@blocksuite/affine-shared/commands';
|
||||
import '@blocksuite/blocks/effects';
|
||||
|
||||
import {
|
||||
AffineEditorContainer,
|
||||
EdgelessEditor,
|
||||
PageEditor,
|
||||
} from './editors/index.js';
|
||||
import { CommentInput } from './fragments/comment/comment-input.js';
|
||||
import { BacklinkButton } from './fragments/doc-meta-tags/backlink-popover.js';
|
||||
import {
|
||||
AFFINE_FRAME_PANEL_BODY,
|
||||
FramePanelBody,
|
||||
} from './fragments/frame-panel/body/frame-panel-body.js';
|
||||
import {
|
||||
AFFINE_FRAME_CARD,
|
||||
FrameCard,
|
||||
} from './fragments/frame-panel/card/frame-card.js';
|
||||
import {
|
||||
AFFINE_FRAME_CARD_TITLE,
|
||||
FrameCardTitle,
|
||||
} from './fragments/frame-panel/card/frame-card-title.js';
|
||||
import {
|
||||
AFFINE_FRAME_TITLE_EDITOR,
|
||||
FrameCardTitleEditor,
|
||||
} from './fragments/frame-panel/card/frame-card-title-editor.js';
|
||||
import {
|
||||
AFFINE_FRAME_PANEL_HEADER,
|
||||
FramePanelHeader,
|
||||
} from './fragments/frame-panel/header/frame-panel-header.js';
|
||||
import {
|
||||
AFFINE_FRAMES_SETTING_MENU,
|
||||
FramesSettingMenu,
|
||||
} from './fragments/frame-panel/header/frames-setting-menu.js';
|
||||
import {
|
||||
AFFINE_FRAME_PANEL,
|
||||
AFFINE_OUTLINE_PANEL,
|
||||
AFFINE_OUTLINE_VIEWER,
|
||||
CommentPanel,
|
||||
DocTitle,
|
||||
FramePanel,
|
||||
OutlinePanel,
|
||||
OutlineViewer,
|
||||
} from './fragments/index.js';
|
||||
import {
|
||||
AFFINE_OUTLINE_NOTICE,
|
||||
OutlineNotice,
|
||||
} from './fragments/outline/body/outline-notice.js';
|
||||
import {
|
||||
AFFINE_OUTLINE_PANEL_BODY,
|
||||
OutlinePanelBody,
|
||||
} from './fragments/outline/body/outline-panel-body.js';
|
||||
import {
|
||||
AFFINE_OUTLINE_NOTE_CARD,
|
||||
OutlineNoteCard,
|
||||
} from './fragments/outline/card/outline-card.js';
|
||||
import {
|
||||
AFFINE_OUTLINE_BLOCK_PREVIEW,
|
||||
OutlineBlockPreview,
|
||||
} from './fragments/outline/card/outline-preview.js';
|
||||
import {
|
||||
AFFINE_OUTLINE_PANEL_HEADER,
|
||||
OutlinePanelHeader,
|
||||
} from './fragments/outline/header/outline-panel-header.js';
|
||||
import {
|
||||
AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU,
|
||||
OutlineNotePreviewSettingMenu,
|
||||
} from './fragments/outline/header/outline-setting-menu.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('page-editor', PageEditor);
|
||||
customElements.define('comment-input', CommentInput);
|
||||
customElements.define('doc-title', DocTitle);
|
||||
customElements.define(
|
||||
AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU,
|
||||
OutlineNotePreviewSettingMenu
|
||||
);
|
||||
customElements.define(AFFINE_FRAME_PANEL, FramePanel);
|
||||
customElements.define(AFFINE_OUTLINE_NOTICE, OutlineNotice);
|
||||
customElements.define('comment-panel', CommentPanel);
|
||||
customElements.define(AFFINE_OUTLINE_PANEL, OutlinePanel);
|
||||
customElements.define('backlink-button', BacklinkButton);
|
||||
customElements.define(AFFINE_OUTLINE_PANEL_HEADER, OutlinePanelHeader);
|
||||
customElements.define('affine-editor-container', AffineEditorContainer);
|
||||
customElements.define(AFFINE_OUTLINE_NOTE_CARD, OutlineNoteCard);
|
||||
customElements.define(AFFINE_FRAME_TITLE_EDITOR, FrameCardTitleEditor);
|
||||
customElements.define('edgeless-editor', EdgelessEditor);
|
||||
customElements.define(AFFINE_FRAME_CARD, FrameCard);
|
||||
customElements.define(AFFINE_OUTLINE_VIEWER, OutlineViewer);
|
||||
customElements.define(AFFINE_FRAME_CARD_TITLE, FrameCardTitle);
|
||||
customElements.define(AFFINE_OUTLINE_BLOCK_PREVIEW, OutlineBlockPreview);
|
||||
customElements.define(AFFINE_FRAME_PANEL_BODY, FramePanelBody);
|
||||
customElements.define(AFFINE_FRAME_PANEL_HEADER, FramePanelHeader);
|
||||
customElements.define(AFFINE_OUTLINE_PANEL_BODY, OutlinePanelBody);
|
||||
customElements.define(AFFINE_FRAMES_SETTING_MENU, FramesSettingMenu);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
export const SmallFrameNavigatorIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.11621 2.63482C2.11621 2.35056 2.34665 2.12012 2.63092 2.12012H3.39072H12.5084H13.2682C13.5524 2.12012 13.7829 2.35056 13.7829 2.63482C13.7829 2.91909 13.5524 3.14953 13.2682 3.14953H13.0231V9.71839H13.2682C13.5524 9.71839 13.7829 9.94884 13.7829 10.2331C13.7829 10.5174 13.5524 10.7478 13.2682 10.7478H8.46425V11.8554L11.0922 13.3571C11.339 13.4981 11.4247 13.8125 11.2837 14.0594C11.1427 14.3062 10.8282 14.3919 10.5814 14.2509L8.46425 13.0411V14.0321C8.46425 14.3164 8.23381 14.5468 7.94954 14.5468C7.66528 14.5468 7.43484 14.3164 7.43484 14.0321V13.0845L5.39364 14.2509C5.14683 14.3919 4.83241 14.3062 4.69138 14.0594C4.55035 13.8125 4.63609 13.4981 4.8829 13.3571L7.43484 11.8989V10.7478H2.63092C2.34665 10.7478 2.11621 10.5174 2.11621 10.2331C2.11621 9.94884 2.34665 9.71839 2.63092 9.71839H2.87601V3.14953H2.63092C2.34665 3.14953 2.11621 2.91909 2.11621 2.63482ZM3.90543 3.14953H11.9937V9.71815H3.90543V3.14953ZM6.18484 6.05396C6.18484 5.7697 5.9544 5.53926 5.67013 5.53926C5.38587 5.53926 5.15543 5.7697 5.15543 6.05396V7.57357C5.15543 7.85783 5.38587 8.08828 5.67013 8.08828C5.9544 8.08828 6.18484 7.85783 6.18484 7.57357V6.05396ZM7.94954 4.3996C8.23381 4.3996 8.46425 4.63004 8.46425 4.91431V7.57362C8.46425 7.85788 8.23381 8.08832 7.94954 8.08832C7.66528 8.08832 7.43484 7.85788 7.43484 7.57362V4.91431C7.43484 4.63004 7.66528 4.3996 7.94954 4.3996ZM10.7437 6.81396C10.7437 6.5297 10.5132 6.29925 10.229 6.29925C9.94469 6.29925 9.71425 6.5297 9.71425 6.81396V7.57376C9.71425 7.85803 9.94469 8.08847 10.229 8.08847C10.5132 8.08847 10.7437 7.85803 10.7437 7.57376V6.81396Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SettingsIcon = html`
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7.9965 3.45031C8.50641 1.3499 11.4936 1.3499 12.0035 3.45031C12.1332 3.9845 12.7452 4.23801 13.2146 3.95198C15.0604 2.82733 17.1727 4.93958 16.048 6.78536C15.762 7.25479 16.0155 7.86681 16.5497 7.9965C18.6501 8.50641 18.6501 11.4936 16.5497 12.0035C16.0155 12.1332 15.762 12.7452 16.048 13.2146C17.1727 15.0604 15.0604 17.1727 13.2146 16.048C12.7452 15.762 12.1332 16.0155 12.0035 16.5497C11.4936 18.6501 8.50641 18.6501 7.9965 16.5497C7.86681 16.0155 7.25479 15.762 6.78536 16.048C4.93958 17.1727 2.82733 15.0604 3.95198 13.2146C4.23801 12.7452 3.9845 12.1332 3.45031 12.0035C1.3499 11.4936 1.3499 8.50641 3.45031 7.9965C3.9845 7.86681 4.23801 7.25479 3.95198 6.78536C2.82733 4.93958 4.93958 2.82733 6.78536 3.95198C7.25479 4.23801 7.86681 3.9845 7.9965 3.45031ZM10.7888 3.74521C10.588 2.91826 9.41197 2.91827 9.21121 3.7452C8.88182 5.10205 7.3273 5.74595 6.13495 5.01944C5.40826 4.57666 4.57666 5.40826 5.01944 6.13495C5.74595 7.3273 5.10205 8.88182 3.7452 9.21121C2.91827 9.41197 2.91826 10.588 3.74521 10.7888C5.10205 11.1182 5.74595 12.6727 5.01944 13.8651C4.57666 14.5917 5.40826 15.4233 6.13495 14.9806C7.3273 14.2541 8.88182 14.898 9.21121 16.2548C9.41197 17.0817 10.588 17.0817 10.7888 16.2548C11.1182 14.898 12.6727 14.2541 13.8651 14.9806C14.5917 15.4233 15.4233 14.5917 14.9806 13.8651C14.2541 12.6727 14.898 11.1182 16.2548 10.7888C17.0817 10.588 17.0817 9.41197 16.2548 9.21121C14.898 8.88182 14.2541 7.3273 14.9806 6.13495C15.4233 5.40826 14.5917 4.57666 13.8651 5.01944C12.6727 5.74595 11.1182 5.10205 10.7888 3.74521ZM10 8.125C8.96447 8.125 8.125 8.96447 8.125 10C8.125 11.0355 8.96447 11.875 10 11.875C11.0355 11.875 11.875 11.0355 11.875 10C11.875 8.96447 11.0355 8.125 10 8.125ZM6.875 10C6.875 8.27411 8.27411 6.875 10 6.875C11.7259 6.875 13.125 8.27411 13.125 10C13.125 11.7259 11.7259 13.125 10 13.125C8.27411 13.125 6.875 11.7259 6.875 10Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
export const BlockPreviewIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M7.77623 1.55279C7.91699 1.4824 8.08268 1.4824 8.22344 1.55279L13.5568 4.21945C13.7262 4.30415 13.8332 4.47728 13.8332 4.66667V11.3333C13.8332 11.5227 13.7262 11.6959 13.5568 11.7805L8.22344 14.4472C8.08268 14.5176 7.91699 14.5176 7.77623 14.4472L2.4429 11.7805C2.27351 11.6959 2.1665 11.5227 2.1665 11.3333V4.66667C2.1665 4.47728 2.27351 4.30415 2.4429 4.21945L7.77623 1.55279ZM3.1665 5.47568L7.49984 7.64235V13.191L3.1665 11.0243V5.47568ZM8.49984 13.191L12.8332 11.0243V5.47568L8.49984 7.64235V13.191ZM7.99984 6.77432L12.2151 4.66667L7.99984 2.55902L3.78454 4.66667L7.99984 6.77432Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallTextIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M2.1665 2.66666C2.1665 2.39051 2.39036 2.16666 2.6665 2.16666H13.3332C13.6093 2.16666 13.8332 2.39051 13.8332 2.66666V4.44443C13.8332 4.72058 13.6093 4.94443 13.3332 4.94443C13.057 4.94443 12.8332 4.72058 12.8332 4.44443V3.16666H8.49984V12.8333H10.6665C10.9426 12.8333 11.1665 13.0572 11.1665 13.3333C11.1665 13.6095 10.9426 13.8333 10.6665 13.8333H5.33317C5.05703 13.8333 4.83317 13.6095 4.83317 13.3333C4.83317 13.0572 5.05703 12.8333 5.33317 12.8333H7.49984V3.16666H3.1665V4.44443C3.1665 4.72058 2.94265 4.94443 2.6665 4.94443C2.39036 4.94443 2.1665 4.72058 2.1665 4.44443V2.66666Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallHeading1Icon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M11.9501 4.38557V11.6509C11.9501 12.052 12.1707 12.2926 12.525 12.2926C12.8859 12.2926 13.0998 12.0587 13.0998 11.6509V3.81744C13.0998 3.36294 12.799 3.0488 12.3712 3.0488C12.1106 3.0488 11.8766 3.14906 11.4555 3.4632L10.1054 4.46578C9.83804 4.65961 9.71105 4.84007 9.71105 5.03391C9.71105 5.28789 9.91156 5.49509 10.1589 5.49509C10.3059 5.49509 10.4463 5.44162 10.6401 5.30126L11.8967 4.38557H11.9501ZM2.94643 3.26836C3.22993 3.26836 3.45975 3.49818 3.45975 3.78168V7.34927H7.57203V3.78168C7.57203 3.49818 7.80185 3.26836 8.08535 3.26836C8.36885 3.26836 8.59867 3.49818 8.59867 3.78168V7.86259V11.9435C8.59867 12.227 8.36885 12.4568 8.08535 12.4568C7.80185 12.4568 7.57203 12.227 7.57203 11.9435V8.37591H3.45975V11.9435C3.45975 12.227 3.22993 12.4568 2.94643 12.4568C2.66293 12.4568 2.43311 12.227 2.43311 11.9435V3.78168C2.43311 3.49818 2.66293 3.26836 2.94643 3.26836Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallHeading2Icon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M9.43899 10.6664C9.06418 11.092 8.95618 11.2699 8.95618 11.4986C8.95618 11.8671 9.24841 12.0894 9.71216 12.0894H13.905C14.2544 12.0894 14.4577 11.9052 14.4577 11.6066C14.4577 11.3017 14.2417 11.1174 13.905 11.1174H10.3792V11.0412L12.7297 8.35396C13.8732 7.05164 14.1972 6.44812 14.1972 5.62226C14.1972 4.22465 13.0982 3.23361 11.5354 3.23361C9.85828 3.23361 8.81007 4.36441 8.81007 5.44438C8.81007 5.78743 9.01336 6.02249 9.32464 6.02249C9.58511 6.02249 9.76298 5.85096 9.85192 5.51426C10.0425 4.6884 10.646 4.19924 11.4655 4.19924C12.4311 4.19924 13.0728 4.7964 13.0728 5.69214C13.0728 6.2893 12.7996 6.82294 12.0817 7.64245L9.43899 10.6664ZM2.27891 3.57571C2.54836 3.57571 2.7668 3.79415 2.7668 4.0636V7.45447H6.67536V4.0636C6.67536 3.79415 6.8938 3.57571 7.16326 3.57571C7.43271 3.57571 7.65115 3.79415 7.65115 4.0636V7.94236V11.8211C7.65115 12.0906 7.43271 12.309 7.16326 12.309C6.8938 12.309 6.67536 12.0906 6.67536 11.8211V8.43026H2.7668V11.8211C2.7668 12.0906 2.54836 12.309 2.27891 12.309C2.00945 12.309 1.79102 12.0906 1.79102 11.8211V4.0636C1.79102 3.79415 2.00945 3.57571 2.27891 3.57571Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallHeading3Icon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M2.69991 3.90388C2.69991 3.62782 2.47612 3.40402 2.20005 3.40402C1.92399 3.40402 1.7002 3.62782 1.7002 3.90388V11.8516C1.7002 12.1277 1.92399 12.3515 2.20005 12.3515C2.47612 12.3515 2.69991 12.1277 2.69991 11.8516V8.37761H6.70432V11.8516C6.70432 12.1277 6.92812 12.3515 7.20418 12.3515C7.48025 12.3515 7.70404 12.1277 7.70404 11.8516V7.87775V3.90388C7.70404 3.62782 7.48025 3.40402 7.20418 3.40402C6.92812 3.40402 6.70432 3.62782 6.70432 3.90388V7.37789H2.69991V3.90388ZM9.01949 10.06C8.74072 10.06 8.54431 10.2628 8.54431 10.5479C8.54431 11.5172 9.74177 12.5056 11.3764 12.5056C13.1377 12.5056 14.3669 11.4665 14.3669 9.98398C14.3669 8.89423 13.5496 7.95654 12.4788 7.83616V7.7728C13.3595 7.62074 14.0754 6.7084 14.0754 5.7517C14.0754 4.43386 12.9477 3.48984 11.3637 3.48984C9.80512 3.48984 8.74705 4.4402 8.74705 5.42224C8.74705 5.73903 8.93713 5.94811 9.22857 5.94811C9.47567 5.94811 9.63406 5.81506 9.76077 5.46659C10.0142 4.81401 10.5844 4.4402 11.3384 4.4402C12.3077 4.4402 12.9603 5.02309 12.9603 5.89108C12.9603 6.75908 12.2887 7.38632 11.3637 7.38632H10.6161C10.312 7.38632 10.1092 7.58273 10.1092 7.8615C10.1092 8.13394 10.3247 8.34302 10.6161 8.34302H11.4081C12.5105 8.34302 13.2518 8.9956 13.2518 9.96497C13.2518 10.9343 12.5295 11.5552 11.3954 11.5552C10.5337 11.5552 9.87482 11.1751 9.54536 10.4972C9.38063 10.174 9.24124 10.06 9.01949 10.06Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallHeading4Icon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M12.5287 10.5032V11.791C12.5287 12.1589 12.719 12.3682 13.0552 12.3682C13.3977 12.3682 13.588 12.1589 13.588 11.791V10.5032H14.2478C14.6093 10.5032 14.8123 10.3256 14.8123 10.0211C14.8123 9.71028 14.603 9.53267 14.2478 9.53267H13.588V4.46418C13.588 3.87424 13.2645 3.54437 12.6873 3.54437C12.2496 3.54437 11.9578 3.73468 11.5835 4.28657C10.1435 6.44337 9.57259 7.35683 8.66546 8.96175C8.44344 9.36773 8.36731 9.58976 8.36731 9.82447C8.36731 10.2431 8.69718 10.5032 9.19197 10.5032H12.5287ZM12.5287 9.53267H9.43303V9.46923C10.264 8.02925 11.3932 6.215 12.4716 4.62277H12.5287V9.53267ZM1.96619 3.80359C2.23525 3.80359 2.45337 4.02171 2.45337 4.29077V7.67669H6.35625V4.29077C6.35625 4.02171 6.57436 3.80359 6.84343 3.80359C7.11249 3.80359 7.33061 4.02171 7.33061 4.29077V8.16387V12.037C7.33061 12.306 7.11249 12.5242 6.84343 12.5242C6.57436 12.5242 6.35625 12.306 6.35625 12.037V8.65106H2.45337V12.037C2.45337 12.306 2.23525 12.5242 1.96619 12.5242C1.69712 12.5242 1.479 12.306 1.479 12.037V4.29077C1.479 4.02171 1.69712 3.80359 1.96619 3.80359Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallHeading5Icon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M9.07668 10.0351C8.804 10.0351 8.62427 10.2458 8.62427 10.5556C8.62427 11.4233 9.64684 12.4211 11.2829 12.4211C13.012 12.4211 14.2453 11.2064 14.2453 9.49589C14.2453 7.89076 13.136 6.76284 11.5618 6.76284C10.8491 6.76284 10.124 7.06031 9.84515 7.47554H9.78318L10.0187 4.7177H13.3591C13.7247 4.7177 13.9292 4.55037 13.9292 4.2529C13.9292 3.94922 13.7185 3.7695 13.3591 3.7695H9.88234C9.34936 3.7695 9.08288 3.98641 9.04569 4.44502L8.77301 7.91555C8.74202 8.39275 8.92175 8.67163 9.275 8.67163C9.47331 8.67163 9.59726 8.59727 10.0497 8.17584C10.4091 7.85358 10.8367 7.69245 11.3201 7.69245C12.3923 7.69245 13.1545 8.46092 13.1545 9.55166C13.1545 10.692 12.3675 11.4852 11.2396 11.4852C10.4959 11.4852 9.92572 11.1258 9.58486 10.4627C9.42373 10.1466 9.28739 10.0351 9.07668 10.0351ZM2.05457 3.9359C2.31743 3.9359 2.53053 4.14899 2.53053 4.41185V7.71977H6.34347V4.41185C6.34347 4.14899 6.55657 3.9359 6.81943 3.9359C7.0823 3.9359 7.29539 4.14899 7.29539 4.41185V8.19573V11.9796C7.29539 12.2425 7.0823 12.4556 6.81943 12.4556C6.55657 12.4556 6.34347 12.2425 6.34347 11.9796V8.67169H2.53053V11.9796C2.53053 12.2425 2.31743 12.4556 2.05457 12.4556C1.79171 12.4556 1.57861 12.2425 1.57861 11.9796V4.41185C1.57861 4.14899 1.79171 3.9359 2.05457 3.9359Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallHeading6Icon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M11.7668 3.6778C9.70114 3.6778 8.55013 5.339 8.55013 8.33165C8.55013 9.61955 8.76166 10.5839 9.19096 11.2683C9.70114 12.0709 10.5411 12.5064 11.5925 12.5064C13.3222 12.5064 14.4919 11.343 14.4919 9.632C14.4919 8.00812 13.3844 6.86955 11.8041 6.86955C10.8335 6.86955 9.94379 7.40461 9.65759 8.16367H9.60782C9.62648 5.83674 10.3606 4.61728 11.7481 4.61728C12.3018 4.61728 12.7684 4.81637 13.2164 5.25812C13.4404 5.47588 13.571 5.54432 13.7453 5.54432C14.0128 5.54432 14.2057 5.34522 14.2057 5.07769C14.2057 4.79149 13.9568 4.44929 13.5462 4.18176C13.0733 3.85823 12.4325 3.6778 11.7668 3.6778ZM11.6361 11.5732C10.5349 11.5732 9.76336 10.783 9.76336 9.66933C9.76336 8.56808 10.5162 7.79658 11.5925 7.79658C12.7 7.79658 13.4217 8.54319 13.4217 9.67555C13.4217 10.8203 12.7062 11.5732 11.6361 11.5732ZM2.30302 3.99417C2.56692 3.99417 2.78085 4.2081 2.78085 4.472V7.79291H6.60878V4.472C6.60878 4.2081 6.82271 3.99417 7.08661 3.99417C7.35051 3.99417 7.56444 4.2081 7.56444 4.472V8.27074V12.0695C7.56444 12.3334 7.35051 12.5473 7.08661 12.5473C6.82271 12.5473 6.60878 12.3334 6.60878 12.0695V8.74857H2.78085V12.0695C2.78085 12.3334 2.56692 12.5473 2.30302 12.5473C2.03913 12.5473 1.8252 12.3334 1.8252 12.0695V4.472C1.8252 4.2081 2.03913 3.99417 2.30302 3.99417Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallCodeBlockIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M1.5 3.99999C1.5 2.98747 2.32081 2.16666 3.33333 2.16666H12.6667C13.6792 2.16666 14.5 2.98747 14.5 3.99999V12C14.5 13.0125 13.6792 13.8333 12.6667 13.8333H3.33333C2.32081 13.8333 1.5 13.0125 1.5 12V3.99999ZM3.33333 3.16666C2.8731 3.16666 2.5 3.53975 2.5 3.99999V12C2.5 12.4602 2.8731 12.8333 3.33333 12.8333H12.6667C13.1269 12.8333 13.5 12.4602 13.5 12V3.99999C13.5 3.53975 13.1269 3.16666 12.6667 3.16666H3.33333ZM7.02022 6.24637C7.21548 6.44163 7.21548 6.75822 7.02022 6.95348L5.95956 8.01414L7.02022 9.0748C7.21548 9.27006 7.21548 9.58664 7.02022 9.78191C6.82496 9.97717 6.50838 9.97717 6.31311 9.78191L4.8989 8.36769C4.70364 8.17243 4.70364 7.85585 4.8989 7.66058L6.31311 6.24637C6.50838 6.05111 6.82496 6.05111 7.02022 6.24637ZM8.97978 6.95348C8.78452 6.75822 8.78452 6.44163 8.97978 6.24637C9.17504 6.05111 9.49162 6.05111 9.68689 6.24637L11.1011 7.66058C11.2964 7.85585 11.2964 8.17243 11.1011 8.36769L9.68689 9.78191C9.49162 9.97717 9.17504 9.97717 8.97978 9.78191C8.78452 9.58664 8.78452 9.27006 8.97978 9.0748L10.0404 8.01414L8.97978 6.95348Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallQuoteBlockIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M3.3335 7.31033V7.39684V10.0405C3.3335 10.4175 3.63908 10.7231 4.01604 10.7231H6.74619C7.12315 10.7231 7.42873 10.4175 7.42873 10.0405V7.31033C7.42873 6.93337 7.12315 6.62779 6.74619 6.62779H4.46965C4.78293 5.77502 5.60225 5.16666 6.56365 5.16666V4.16666C4.83323 4.16666 3.42062 5.52736 3.33738 7.23713C3.33481 7.26118 3.3335 7.2856 3.3335 7.31033ZM8.66683 7.31033V7.39684V10.0405C8.66683 10.4175 8.97242 10.7231 9.34937 10.7231H12.0795C12.4565 10.7231 12.7621 10.4175 12.7621 10.0405V7.31033C12.7621 6.93337 12.4565 6.62779 12.0795 6.62779H9.80298C10.1163 5.77502 10.9356 5.16666 11.897 5.16666V4.16666C10.1666 4.16666 8.75396 5.52736 8.67071 7.23713C8.66815 7.26118 8.66683 7.2856 8.66683 7.31033Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallNumberListIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M2.75263 3.84138C2.7875 3.82966 2.81892 3.81149 2.85247 3.7882L3.02274 3.67024V5.09333C3.02274 5.21841 3.06494 5.32694 3.14381 5.40422C3.22253 5.48134 3.33201 5.52173 3.45631 5.52173C3.58061 5.52173 3.69008 5.48134 3.7688 5.40422C3.84767 5.32694 3.88988 5.21841 3.88988 5.09333V3.28924C3.88988 3.15004 3.84638 3.02903 3.75909 2.94279C3.67187 2.85661 3.54844 2.81259 3.40289 2.81259C3.25521 2.81259 3.11383 2.82725 2.95315 2.93437L2.50128 3.23619C2.38666 3.31397 2.3335 3.41753 2.3335 3.54254C2.3335 3.72466 2.46885 3.85721 2.643 3.85721C2.68225 3.85721 2.71723 3.85327 2.75263 3.84138ZM6.27061 3.73435C5.98781 3.73433 5.75855 3.96357 5.75853 4.24636C5.75851 4.52916 5.98775 4.75843 6.27055 4.75844L13.1548 4.75886C13.4375 4.75887 13.6668 4.52964 13.6668 4.24684C13.6668 3.96404 13.4376 3.73478 13.1548 3.73476L6.27061 3.73435ZM6.27061 7.48937C5.98781 7.48935 5.75855 7.71859 5.75853 8.00139C5.75851 8.28419 5.98775 8.51345 6.27055 8.51347L13.1548 8.51388C13.4375 8.5139 13.6668 8.28466 13.6668 8.00186C13.6668 7.71907 13.4376 7.4898 13.1548 7.48978L6.27061 7.48937ZM6.27061 11.2444C5.98781 11.2444 5.75855 11.4736 5.75853 11.7564C5.75851 12.0392 5.98775 12.2685 6.27055 12.2685L13.1548 12.2689C13.4375 12.2689 13.6668 12.0397 13.6668 11.7569C13.6668 11.4741 13.4376 11.2448 13.1548 11.2448L6.27061 11.2444ZM4.41634 7.40936C4.42422 7.36536 4.4281 7.31884 4.4281 7.26872C4.4281 6.81727 4.04212 6.51228 3.43732 6.51228C3.00309 6.51228 2.65331 6.69665 2.52235 6.99302C2.51378 7.0128 2.50697 7.03283 2.50184 7.05321C2.50698 7.03281 2.51379 7.01276 2.52237 6.99296C2.65332 6.69658 3.00311 6.51221 3.43733 6.51221C4.04214 6.51221 4.42811 6.8172 4.42811 7.26865C4.42811 7.3188 4.42423 7.36534 4.41634 7.40936ZM3.41666 8.50244L3.41664 8.50246V8.51624H4.20582C4.38847 8.51624 4.49185 8.6248 4.49185 8.79194C4.49185 8.81314 4.49012 8.83346 4.4867 8.8528C4.49013 8.83344 4.49187 8.81309 4.49187 8.79187C4.49187 8.62473 4.38848 8.51618 4.20583 8.51618H3.41666V8.50244ZM2.49348 8.6609C2.51659 8.5679 2.5776 8.48696 2.68261 8.39901L3.36669 7.81315C3.59238 7.6195 3.68741 7.51146 3.71552 7.39886C3.68743 7.51148 3.5924 7.61953 3.36667 7.81322L2.6826 8.39907C2.57761 8.487 2.5166 8.56793 2.49348 8.6609ZM3.00245 7.45443C3.06576 7.41681 3.11852 7.35977 3.16964 7.28495C3.21335 7.22183 3.25281 7.18128 3.29247 7.1562C3.33084 7.13193 3.37305 7.11981 3.42699 7.11981C3.50193 7.11981 3.55935 7.14261 3.59712 7.17634C3.63429 7.20954 3.65682 7.25704 3.65682 7.3169C3.65682 7.42985 3.60418 7.51941 3.32223 7.76134L2.63848 8.34692C2.49238 8.46935 2.41446 8.59008 2.41446 8.75224C2.41446 8.85608 2.44402 8.95426 2.51569 9.02649C2.58758 9.09893 2.69247 9.13584 2.82391 9.13584H4.20583C4.3113 9.13584 4.40151 9.10321 4.46551 9.03949C4.52944 8.97584 4.56014 8.88822 4.56014 8.79187C4.56014 8.69411 4.52962 8.60621 4.4653 8.54275C4.40102 8.47933 4.31071 8.4479 4.20583 8.4479H3.58427L3.91453 8.16286C4.10644 7.99664 4.25236 7.86448 4.34915 7.72979C4.44874 7.59122 4.49639 7.45023 4.49639 7.26865C4.49639 7.02144 4.38982 6.81248 4.20121 6.66707C4.01414 6.52284 3.75049 6.44394 3.43733 6.44394C2.98691 6.44394 2.6054 6.63612 2.45972 6.96581C2.43335 7.02666 2.42135 7.08939 2.42135 7.15493C2.42135 7.25795 2.45508 7.34823 2.52273 7.41249C2.58996 7.47636 2.68328 7.50751 2.78945 7.50751C2.86827 7.50751 2.93794 7.49275 3.00245 7.45443ZM2.44775 10.719C2.54915 10.4427 2.89358 10.1498 3.48551 10.1498C3.79028 10.1498 4.06201 10.2143 4.2587 10.3429C4.45674 10.4724 4.57779 10.6666 4.57779 10.9199C4.57779 11.2418 4.37235 11.4559 4.11613 11.5475C4.27228 11.5803 4.40301 11.6428 4.5004 11.7332C4.62307 11.8472 4.69016 12.003 4.69016 12.1916C4.69016 12.461 4.57389 12.6824 4.36382 12.8353C4.15486 12.9874 3.85534 13.0703 3.48925 13.0703C2.84939 13.0703 2.49302 12.7649 2.39522 12.4915C2.3769 12.4406 2.36887 12.3842 2.36887 12.3395C2.36887 12.2333 2.40451 12.144 2.47207 12.0815C2.53931 12.0193 2.63451 11.987 2.74761 11.987C2.82533 11.987 2.89225 11.9998 2.95092 12.0293C3.00974 12.0588 3.05785 12.1039 3.09973 12.1647C3.14935 12.2373 3.19619 12.2916 3.2569 12.3283C3.31713 12.3648 3.39437 12.3859 3.50798 12.3859C3.69342 12.3859 3.8147 12.276 3.8147 12.1335C3.8147 12.0471 3.78226 11.9841 3.7209 11.9412C3.65761 11.897 3.55985 11.8717 3.42558 11.8717H3.40685C3.30795 11.8717 3.22859 11.8442 3.1739 11.7906C3.11916 11.7369 3.09366 11.6617 3.09366 11.5754C3.09366 11.4925 3.11937 11.418 3.17378 11.3641C3.22823 11.3102 3.30745 11.281 3.40685 11.281H3.42558C3.54389 11.281 3.63283 11.2551 3.69126 11.2124C3.74841 11.1706 3.77911 11.1108 3.77911 11.0342C3.77911 10.9599 3.75194 10.902 3.70443 10.862C3.65624 10.8215 3.58381 10.7968 3.48925 10.7968C3.41343 10.7968 3.35069 10.8121 3.29851 10.8419C3.24639 10.8716 3.20271 10.9169 3.16682 10.9799C3.12025 11.0624 3.06988 11.122 3.00589 11.1606C2.94171 11.1992 2.86754 11.2144 2.77758 11.2144C2.66225 11.2144 2.57171 11.1804 2.50997 11.1187C2.44832 11.0571 2.41944 10.9717 2.41944 10.8769C2.41944 10.8202 2.42781 10.7749 2.44775 10.719Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallBulletListIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M2.86686 4.99999C3.23505 4.99999 3.53353 4.70151 3.53353 4.33332C3.53353 3.96513 3.23505 3.66666 2.86686 3.66666C2.49867 3.66666 2.2002 3.96513 2.2002 4.33332C2.2002 4.70151 2.49867 4.99999 2.86686 4.99999ZM5.55577 3.83381C5.27963 3.8338 5.05576 4.05764 5.05575 4.33378C5.05573 4.60993 5.27958 4.8338 5.55572 4.83381L13.5001 4.83425C13.7763 4.83427 14.0002 4.61042 14.0002 4.33428C14.0002 4.05814 13.7763 3.83427 13.5002 3.83425L5.55577 3.83381ZM5.55578 7.50051C5.27963 7.5005 5.05576 7.72434 5.05575 8.00048C5.05573 8.27662 5.27957 8.5005 5.55572 8.50051L13.5002 8.50099C13.7763 8.50101 14.0002 8.27716 14.0002 8.00102C14.0002 7.72488 13.7764 7.50101 13.5002 7.50099L5.55578 7.50051ZM5.55578 11.1672C5.27963 11.1672 5.05576 11.391 5.05575 11.6671C5.05573 11.9433 5.27957 12.1672 5.55571 12.1672L13.5002 12.1677C13.7763 12.1677 14.0002 11.9438 14.0002 11.6677C14.0002 11.3915 13.7764 11.1677 13.5002 11.1677L5.55578 11.1672ZM3.53353 7.99999C3.53353 8.36818 3.23505 8.66666 2.86686 8.66666C2.49867 8.66666 2.2002 8.36818 2.2002 7.99999C2.2002 7.6318 2.49867 7.33332 2.86686 7.33332C3.23505 7.33332 3.53353 7.6318 3.53353 7.99999ZM2.86686 12.3333C3.23505 12.3333 3.53353 12.0348 3.53353 11.6667C3.53353 11.2985 3.23505 11 2.86686 11C2.49867 11 2.2002 11.2985 2.2002 11.6667C2.2002 12.0348 2.49867 12.3333 2.86686 12.3333Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallTodoIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M3.99984 2.16666C2.98732 2.16666 2.1665 2.98747 2.1665 3.99999V12C2.1665 13.0125 2.98732 13.8333 3.99984 13.8333H11.9998C13.0124 13.8333 13.8332 13.0125 13.8332 12V3.99999C13.8332 2.98747 13.0124 2.16666 11.9998 2.16666H3.99984ZM3.1665 3.99999C3.1665 3.53975 3.5396 3.16666 3.99984 3.16666H11.9998C12.4601 3.16666 12.8332 3.53975 12.8332 3.99999V12C12.8332 12.4602 12.4601 12.8333 11.9998 12.8333H3.99984C3.5396 12.8333 3.1665 12.4602 3.1665 12V3.99999ZM11.0201 6.35354C11.2153 6.15828 11.2153 5.8417 11.0201 5.64644C10.8248 5.45117 10.5082 5.45117 10.3129 5.64644L6.99984 8.95955L6.02006 7.97977C5.82479 7.78451 5.50821 7.78451 5.31295 7.97977C5.11769 8.17503 5.11769 8.49161 5.31295 8.68688L6.64628 10.0202C6.84155 10.2155 7.15813 10.2155 7.35339 10.0202L11.0201 6.35354Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallBookmarkIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M3.33333 2.16669C2.32081 2.16669 1.5 2.9875 1.5 4.00002V8.66669C1.5 8.94283 1.72386 9.16669 2 9.16669C2.27614 9.16669 2.5 8.94283 2.5 8.66669V4.00002C2.5 3.53978 2.8731 3.16669 3.33333 3.16669H12.6667C13.1269 3.16669 13.5 3.53978 13.5 4.00002V12C13.5 12.4603 13.1269 12.8334 12.6667 12.8334H8C7.72386 12.8334 7.5 13.0572 7.5 13.3334C7.5 13.6095 7.72386 13.8334 8 13.8334H12.6667C13.6792 13.8334 14.5 13.0125 14.5 12V4.00002C14.5 2.9875 13.6792 2.16669 12.6667 2.16669H3.33333ZM6.07741 8.7441C6.40285 8.41866 6.93048 8.41866 7.25592 8.7441C7.58136 9.06953 7.58136 9.59717 7.25592 9.92261L5.92259 11.2559C5.59715 11.5814 5.06951 11.5814 4.74408 11.2559C4.54882 11.0607 4.23223 11.0607 4.03697 11.2559C3.84171 11.4512 3.84171 11.7678 4.03697 11.963C4.75293 12.679 5.91373 12.679 6.6297 11.963L7.96303 10.6297C8.67899 9.91375 8.67899 8.75295 7.96303 8.03699C7.24707 7.32103 6.08627 7.32103 5.3703 8.03699L5.00377 8.40353C4.80851 8.59879 4.80851 8.91537 5.00377 9.11063C5.19903 9.3059 5.51561 9.3059 5.71087 9.11063L6.07741 8.7441ZM4.07741 10.7441C4.40285 10.4187 4.93049 10.4187 5.25592 10.7441C5.45118 10.9394 5.76777 10.9394 5.96303 10.7441C6.15829 10.5488 6.15829 10.2323 5.96303 10.037C5.24707 9.32103 4.08627 9.32103 3.3703 10.037L2.03697 11.3703C1.32101 12.0863 1.32101 13.2471 2.03697 13.963C2.75293 14.679 3.91373 14.679 4.6297 13.963L4.99688 13.5959C5.19215 13.4006 5.19215 13.084 4.99688 12.8888C4.80162 12.6935 4.48504 12.6935 4.28978 12.8888L3.92259 13.2559C3.59715 13.5814 3.06951 13.5814 2.74408 13.2559C2.41864 12.9305 2.41864 12.4029 2.74408 12.0774L4.07741 10.7441Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallImageIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M3.99984 2.16669C2.98732 2.16669 2.1665 2.9875 2.1665 4.00002V10.6667V12C2.1665 13.0125 2.98732 13.8334 3.99984 13.8334H11.9998C13.0124 13.8334 13.8332 13.0125 13.8332 12V9.33335V4.00002C13.8332 2.9875 13.0124 2.16669 11.9998 2.16669H3.99984ZM3.1665 12V10.8738L6.07725 7.96305C6.40268 7.63761 6.93032 7.63761 7.25576 7.96305L8.97962 9.68691L10.313 11.0202C10.5082 11.2155 10.8248 11.2155 11.0201 11.0202C11.2153 10.825 11.2153 10.5084 11.0201 10.3131L10.0403 9.33335L10.7439 8.62972C11.0694 8.30428 11.597 8.30428 11.9224 8.62972L12.8332 9.54046V12C12.8332 12.4603 12.4601 12.8334 11.9998 12.8334H3.99984C3.5396 12.8334 3.1665 12.4603 3.1665 12ZM7.96287 7.25594L9.33317 8.62625L10.0368 7.92261C10.7528 7.20665 11.9136 7.20665 12.6295 7.92261L12.8332 8.12625V4.00002C12.8332 3.53978 12.4601 3.16669 11.9998 3.16669H3.99984C3.5396 3.16669 3.1665 3.53978 3.1665 4.00002V9.45958L5.37014 7.25594C6.0861 6.53998 7.2469 6.53998 7.96287 7.25594ZM9.33317 6.00002C9.70136 6.00002 9.99984 5.70154 9.99984 5.33335C9.99984 4.96516 9.70136 4.66669 9.33317 4.66669C8.96498 4.66669 8.6665 4.96516 8.6665 5.33335C8.6665 5.70154 8.96498 6.00002 9.33317 6.00002Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallDatabaseTableIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M2.1665 4.00002C2.1665 2.9875 2.98732 2.16669 3.99984 2.16669H11.9998C13.0124 2.16669 13.8332 2.9875 13.8332 4.00002V12C13.8332 13.0125 13.0124 13.8334 11.9998 13.8334H3.99984C2.98732 13.8334 2.1665 13.0125 2.1665 12V4.00002ZM3.99984 3.16669C3.5396 3.16669 3.1665 3.53978 3.1665 4.00002V5.50002H12.8332V4.00002C12.8332 3.53978 12.4601 3.16669 11.9998 3.16669H3.99984ZM3.1665 12V6.50002H5.1665V12.8334H3.99984C3.5396 12.8334 3.1665 12.4603 3.1665 12ZM6.1665 12.8334H11.9998C12.4601 12.8334 12.8332 12.4603 12.8332 12V6.50002H6.1665V12.8334Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallDatabaseKanbanIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M3.66667 2.5C3.02233 2.5 2.5 3.02233 2.5 3.66667V12C2.5 12.6443 3.02233 13.1667 3.66667 13.1667H6.33333C6.97767 13.1667 7.5 12.6443 7.5 12V3.66667C7.5 3.02233 6.97767 2.5 6.33333 2.5H3.66667ZM3.5 3.66667C3.5 3.57462 3.57462 3.5 3.66667 3.5H6.33333C6.42538 3.5 6.5 3.57462 6.5 3.66667V12C6.5 12.092 6.42538 12.1667 6.33333 12.1667H3.66667C3.57462 12.1667 3.5 12.092 3.5 12V3.66667ZM9.66667 2.5C9.02233 2.5 8.5 3.02233 8.5 3.66667V8.33333C8.5 8.97767 9.02233 9.5 9.66667 9.5H12.3333C12.9777 9.5 13.5 8.97767 13.5 8.33333V3.66667C13.5 3.02233 12.9777 2.5 12.3333 2.5H9.66667ZM9.5 3.66667C9.5 3.57462 9.57462 3.5 9.66667 3.5H12.3333C12.4254 3.5 12.5 3.57462 12.5 3.66667V8.33333C12.5 8.42538 12.4254 8.5 12.3333 8.5H9.66667C9.57462 8.5 9.5 8.42538 9.5 8.33333V3.66667Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallAttachmentIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M8.14486 3.73868L4.11805 7.76553C2.84936 9.00118 2.84933 10.9989 4.11802 12.2346C5.39409 13.4774 7.46833 13.4774 8.7444 12.2346L12.671 8.41922C12.8691 8.22678 13.1856 8.23133 13.378 8.42938C13.5705 8.62742 13.5659 8.94397 13.3679 9.13641L9.44212 12.9509C9.44204 12.951 9.44219 12.9509 9.44212 12.9509C7.77775 14.5717 5.08458 14.5719 3.4203 12.9509C1.74938 11.3235 1.74857 8.67989 3.41786 7.0515L7.44488 3.02445C8.61916 1.88075 10.5177 1.88078 11.692 3.02448C12.8729 4.17456 12.8737 6.04431 11.6945 7.1954L7.66745 11.2225C6.98324 11.8889 5.87921 11.8889 5.195 11.2225C4.50341 10.5489 4.50341 9.45119 5.195 8.77761L9.32726 4.75296C9.52508 4.56029 9.84163 4.56447 10.0343 4.76229C10.227 4.96011 10.2228 5.27666 10.025 5.46933L5.89272 9.49398C5.60417 9.77501 5.60417 10.2251 5.89272 10.5061C6.18796 10.7936 6.67153 10.7943 6.96765 10.5081L10.9943 6.48141C11.7729 5.72307 11.7729 4.49919 10.9943 3.74085C10.2091 2.97604 8.93105 2.97532 8.14486 3.73868Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallLinkedDocIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M2.8335 4.00002C2.8335 2.9875 3.65431 2.16669 4.66683 2.16669H11.3335C12.346 2.16669 13.1668 2.9875 13.1668 4.00002V8.00002C13.1668 8.27616 12.943 8.50002 12.6668 8.50002C12.3907 8.50002 12.1668 8.27616 12.1668 8.00002V4.00002C12.1668 3.53978 11.7937 3.16669 11.3335 3.16669H4.66683C4.20659 3.16669 3.8335 3.53978 3.8335 4.00002V12C3.8335 12.4603 4.20659 12.8334 4.66683 12.8334H8.00016C8.27631 12.8334 8.50016 13.0572 8.50016 13.3334C8.50016 13.6095 8.27631 13.8334 8.00016 13.8334H4.66683C3.65431 13.8334 2.8335 13.0125 2.8335 12V4.00002ZM5.50016 5.33335C5.50016 5.05721 5.72402 4.83335 6.00016 4.83335H8.00016C8.27631 4.83335 8.50016 5.05721 8.50016 5.33335C8.50016 5.6095 8.27631 5.83335 8.00016 5.83335H6.00016C5.72402 5.83335 5.50016 5.6095 5.50016 5.33335ZM6.00016 7.16669C5.72402 7.16669 5.50016 7.39054 5.50016 7.66669C5.50016 7.94283 5.72402 8.16669 6.00016 8.16669H10.0002C10.2763 8.16669 10.5002 7.94283 10.5002 7.66669C10.5002 7.39054 10.2763 7.16669 10.0002 7.16669H6.00016ZM5.50016 10C5.50016 9.72388 5.72402 9.50002 6.00016 9.50002H7.66683C7.94297 9.50002 8.16683 9.72388 8.16683 10C8.16683 10.2762 7.94297 10.5 7.66683 10.5H6.00016C5.72402 10.5 5.50016 10.2762 5.50016 10ZM10.3335 9.50002C10.0574 9.50002 9.8335 9.72388 9.8335 10C9.8335 10.2762 10.0574 10.5 10.3335 10.5H12.1264L9.64661 12.9798C9.45135 13.1751 9.45135 13.4916 9.64661 13.6869C9.84187 13.8822 10.1585 13.8822 10.3537 13.6869L12.8335 11.2071V13C12.8335 13.2762 13.0574 13.5 13.3335 13.5C13.6096 13.5 13.8335 13.2762 13.8335 13V10C13.8335 9.86741 13.7808 9.74023 13.687 9.64647C13.5933 9.5527 13.4661 9.50002 13.3335 9.50002H10.3335Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallDeleteIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7.33334 1.5C6.32082 1.5 5.50001 2.32081 5.50001 3.33333V4.09994H2.66667C2.35371 4.09994 2.10001 4.35364 2.10001 4.6666C2.10001 4.97956 2.35371 5.23327 2.66667 5.23327H2.87254L3.41282 12.7973C3.48135 13.7567 4.27966 14.5 5.2415 14.5H10.7585C11.7204 14.5 12.5187 13.7567 12.5872 12.7973L13.1275 5.23327H13.3333C13.6463 5.23327 13.9 4.97956 13.9 4.6666C13.9 4.35364 13.6463 4.09994 13.3333 4.09994H10.5V3.33333C10.5 2.32081 9.67919 1.5 8.66667 1.5H7.33334ZM9.50001 4.09994V3.33333C9.50001 2.8731 9.12691 2.5 8.66667 2.5H7.33334C6.8731 2.5 6.50001 2.8731 6.50001 3.33333V4.09994H9.50001ZM12.1249 5.23327H3.87508L4.41028 12.726C4.44143 13.1621 4.8043 13.5 5.2415 13.5H10.7585C11.1957 13.5 11.5586 13.1621 11.5897 12.726L12.1249 5.23327ZM7.16667 7.33333C7.16667 7.05719 6.94281 6.83333 6.66667 6.83333C6.39053 6.83333 6.16667 7.05719 6.16667 7.33333V11.3333C6.16667 11.6095 6.39053 11.8333 6.66667 11.8333C6.94281 11.8333 7.16667 11.6095 7.16667 11.3333V7.33333ZM9.33334 6.83333C9.60948 6.83333 9.83334 7.05719 9.83334 7.33333V11.3333C9.83334 11.6095 9.60948 11.8333 9.33334 11.8333C9.0572 11.8333 8.83334 11.6095 8.83334 11.3333V7.33333C8.83334 7.05719 9.0572 6.83333 9.33334 6.83333Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SortingIcon = html`<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M14.7916 13.9062C14.7916 14.2514 14.5118 14.5312 14.1666 14.5312C13.8214 14.5312 13.5416 14.2514 13.5416 13.9062V5.10263L10.8585 7.78569C10.6144 8.02977 10.2187 8.02977 9.97464 7.78569C9.73057 7.54161 9.73057 7.14589 9.97464 6.90181L13.7246 3.15181C13.9687 2.90773 14.3644 2.90773 14.6085 3.15181L18.3585 6.90181C18.6026 7.14589 18.6026 7.54161 18.3585 7.78569C18.1144 8.02977 17.7187 8.02977 17.4746 7.78569L14.7916 5.10263V13.9062ZM6.45825 6.09375C6.45825 5.74857 6.17843 5.46875 5.83325 5.46875C5.48807 5.46875 5.20825 5.74857 5.20825 6.09375V14.8974L2.52519 12.2143C2.28112 11.9702 1.88539 11.9702 1.64131 12.2143C1.39723 12.4584 1.39723 12.8541 1.64131 13.0982L5.39131 16.8482C5.63539 17.0923 6.03112 17.0923 6.27519 16.8482L10.0252 13.0982C10.2693 12.8541 10.2693 12.4584 10.0252 12.2143C9.78112 11.9702 9.38539 11.9702 9.14131 12.2143L6.45825 14.8974V6.09375Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const ArrowLeftIcon = html`<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.0265 9.4584C13.4358 9.69904 13.4358 10.3006 13.0265 10.5413L8.42122 13.2485C8.01186 13.4891 7.50016 13.1883 7.50016 12.707L7.50016 7.29264C7.50016 6.81136 8.01186 6.51056 8.42122 6.7512L13.0265 9.4584Z"
|
||||
fill="#77757D"
|
||||
/>
|
||||
</svg> `;
|
||||
|
||||
export const ArrowJumpIcon = html`<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_3398_22894)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.8359 5.4105C10.6024 5.66474 10.6192 6.06011 10.8735 6.29359L14.2287 9.37492L4.16658 9.37492C3.82141 9.37492 3.54158 9.65474 3.54158 9.99992C3.54158 10.3451 3.82141 10.6249 4.16658 10.6249L14.2287 10.6249L10.8735 13.7063C10.6192 13.9397 10.6024 14.3351 10.8359 14.5893C11.0694 14.8436 11.4647 14.8604 11.719 14.6269L16.256 10.4603C16.3849 10.3419 16.4583 10.1749 16.4583 9.99992C16.4583 9.82493 16.3849 9.65796 16.256 9.53959L11.719 5.37292C11.4647 5.13944 11.0694 5.15627 10.8359 5.4105Z"
|
||||
fill="#77757D"
|
||||
fill-opacity="0.6"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3398_22894">
|
||||
<rect width="20" height="20" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg> `;
|
||||
|
||||
export const HiddenIcon = html`<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M3.83277 6.29118C3.62547 6.01519 3.23368 5.9595 2.95769 6.16681C2.68169 6.37411 2.62601 6.76591 2.83331 7.0419C3.50188 7.93198 4.33282 8.69373 5.28223 9.28306L3.64659 11.7365C3.45512 12.0237 3.53273 12.4118 3.81994 12.6032C4.10714 12.7947 4.49519 12.7171 4.68666 12.4299L6.35332 9.92989C6.36688 9.90956 6.37909 9.88872 6.38997 9.86748C7.31361 10.2747 8.31912 10.5306 9.37496 10.6034V14.5832C9.37496 14.9284 9.65478 15.2082 9.99996 15.2082C10.3451 15.2082 10.625 14.9284 10.625 14.5832V10.6035C11.6808 10.5307 12.6864 10.2749 13.6101 9.86776C13.6209 9.8889 13.6331 9.90965 13.6466 9.92989L15.3133 12.4299C15.5047 12.7171 15.8928 12.7947 16.18 12.6032C16.4672 12.4118 16.5448 12.0237 16.3533 11.7365L14.7179 9.2834C15.6675 8.69402 16.4987 7.93215 17.1674 7.0419C17.3747 6.76591 17.319 6.37411 17.043 6.16681C16.767 5.9595 16.3752 6.01519 16.1679 6.29118C14.7605 8.16488 12.5218 9.3749 10.0003 9.3749C7.47885 9.3749 5.24015 8.16488 3.83277 6.29118Z"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallArrowDownIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.31344 6.31295C4.5087 6.11769 4.82528 6.11769 5.02055 6.31295L7.76462 9.05703C7.8948 9.1872 8.10585 9.1872 8.23603 9.05703L10.9801 6.31295C11.1754 6.11769 11.492 6.11769 11.6872 6.31295C11.8825 6.50821 11.8825 6.8248 11.6872 7.02006L8.94313 9.76414C8.42244 10.2848 7.57822 10.2848 7.05752 9.76414L4.31344 7.02006C4.11818 6.8248 4.11818 6.50821 4.31344 6.31295Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const SmallCloseIcon = html`<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.64645 3.64645C3.84171 3.45118 4.15829 3.45118 4.35355 3.64645L8 7.29289L11.6464 3.64645C11.8417 3.45118 12.1583 3.45118 12.3536 3.64645C12.5488 3.84171 12.5488 4.15829 12.3536 4.35355L8.70711 8L12.3536 11.6464C12.5488 11.8417 12.5488 12.1583 12.3536 12.3536C12.1583 12.5488 11.8417 12.5488 11.6464 12.3536L8 8.70711L4.35355 12.3536C4.15829 12.5488 3.84171 12.5488 3.64645 12.3536C3.45118 12.1583 3.45118 11.8417 3.64645 11.6464L7.29289 8L3.64645 4.35355C3.45118 4.15829 3.45118 3.84171 3.64645 3.64645Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const TocIcon = html`
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
style="user-select:none;flex-shrink:0;"
|
||||
>
|
||||
<path
|
||||
fill="#7A7A7A"
|
||||
fill-rule="evenodd"
|
||||
d="M3.25 6A.75.75 0 0 1 4 5.25h13a.75.75 0 0 1 0 1.5H4A.75.75 0 0 1 3.25 6ZM3.25 12a.75.75 0 0 1 .75-.75h13a.75.75 0 0 1 0 1.5H4a.75.75 0 0 1-.75-.75ZM3.25 18a.75.75 0 0 1 .75-.75h13a.75.75 0 0 1 0 1.5H4a.75.75 0 0 1-.75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="#7A7A7A"
|
||||
d="M20.8 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM20.8 18a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM20.8 6a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { TextSelection } from '@blocksuite/block-std';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import type { RichText } from '@blocksuite/blocks';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
import type { Comment, CommentManager } from './comment-manager.js';
|
||||
|
||||
export class CommentInput extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.comment-input-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.comment-quote {
|
||||
font-size: 10px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--affine-text-secondary-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-editor {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
min-height: 24px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comment-control {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _cancel = () => {
|
||||
this.remove();
|
||||
};
|
||||
|
||||
private _submit = (textSelection: TextSelection) => {
|
||||
const deltas = this._editor.inlineEditor?.yTextDeltas;
|
||||
if (!deltas) {
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const yText = new DocCollection.Y.Text();
|
||||
yText.applyDelta(deltas);
|
||||
const comment = this.manager.addComment(textSelection, {
|
||||
author: 'Anonymous',
|
||||
text: yText,
|
||||
});
|
||||
|
||||
this.onSubmit?.(comment);
|
||||
|
||||
this.remove();
|
||||
};
|
||||
|
||||
get host() {
|
||||
return this.manager.host;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const textSelection = this.host.selection.find('text');
|
||||
if (!textSelection) {
|
||||
this.remove();
|
||||
return nothing;
|
||||
}
|
||||
const parseResult = this.manager.parseTextSelection(textSelection);
|
||||
if (!parseResult) {
|
||||
this.remove();
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { quote } = parseResult;
|
||||
|
||||
const tmpYDoc = new DocCollection.Y.Doc();
|
||||
const tmpYText = tmpYDoc.getText('comment');
|
||||
|
||||
return html`<div class="comment-input-container">
|
||||
<div class="comment-state">
|
||||
<div class="comment-quote">${quote}</div>
|
||||
<div class="comment-author">Anonymous</div>
|
||||
</div>
|
||||
<rich-text
|
||||
@blur=${() => this._submit(textSelection)}
|
||||
.yText=${tmpYText}
|
||||
class="comment-editor"
|
||||
></rich-text>
|
||||
<div class="comment-control">
|
||||
<button
|
||||
@click=${() => this._submit(textSelection)}
|
||||
class="comment-submit"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button @click=${this._cancel} class="comment-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _editor!: RichText;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor manager!: CommentManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onSubmit: undefined | ((comment: Comment) => void) = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'comment-input': CommentInput;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { EditorHost, TextSelection } from '@blocksuite/block-std';
|
||||
import { DocCollection, type Y } from '@blocksuite/store';
|
||||
|
||||
export interface CommentMeta {
|
||||
id: string;
|
||||
date: number;
|
||||
}
|
||||
export interface CommentRange {
|
||||
start: {
|
||||
id: string;
|
||||
index: Y.RelativePosition;
|
||||
};
|
||||
end: {
|
||||
id: string;
|
||||
index: Y.RelativePosition;
|
||||
};
|
||||
}
|
||||
export interface CommentContent {
|
||||
quote: string;
|
||||
author: string;
|
||||
text: Y.Text;
|
||||
}
|
||||
|
||||
export type Comment = CommentMeta & CommentRange & CommentContent;
|
||||
|
||||
export class CommentManager {
|
||||
private get _command() {
|
||||
return this.host.command;
|
||||
}
|
||||
|
||||
get commentsMap() {
|
||||
return this.host.doc.spaceDoc.getMap<Y.Map<unknown>>('comments');
|
||||
}
|
||||
|
||||
constructor(readonly host: EditorHost) {}
|
||||
|
||||
addComment(
|
||||
selection: TextSelection,
|
||||
payload: Pick<CommentContent, 'author' | 'text'>
|
||||
): Comment {
|
||||
const parseResult = this.parseTextSelection(selection);
|
||||
if (!parseResult) {
|
||||
throw new Error('Invalid selection');
|
||||
}
|
||||
|
||||
const { quote, range } = parseResult;
|
||||
const id = this.host.doc.collection.idGenerator();
|
||||
const comment: Comment = {
|
||||
id,
|
||||
date: Date.now(),
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
quote,
|
||||
...payload,
|
||||
};
|
||||
this.commentsMap.set(
|
||||
id,
|
||||
new DocCollection.Y.Map<unknown>(Object.entries(comment))
|
||||
);
|
||||
return comment;
|
||||
}
|
||||
|
||||
getComments(): Comment[] {
|
||||
const comments: Comment[] = [];
|
||||
this.commentsMap.forEach((comment, key) => {
|
||||
const start = comment.get('start') as Comment['start'];
|
||||
const end = comment.get('end') as Comment['end'];
|
||||
|
||||
const startIndex =
|
||||
DocCollection.Y.createAbsolutePositionFromRelativePosition(
|
||||
start.index,
|
||||
this.host.doc.spaceDoc
|
||||
);
|
||||
const startBlock = this.host.view.getBlock(start.id);
|
||||
const endIndex =
|
||||
DocCollection.Y.createAbsolutePositionFromRelativePosition(
|
||||
end.index,
|
||||
this.host.doc.spaceDoc
|
||||
);
|
||||
const endBlock = this.host.view.getBlock(end.id);
|
||||
|
||||
if (!startIndex || !startBlock || !endIndex || !endBlock) {
|
||||
// remove outdated comment
|
||||
this.commentsMap.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Comment = {
|
||||
id: comment.get('id') as Comment['id'],
|
||||
date: comment.get('date') as Comment['date'],
|
||||
start,
|
||||
end,
|
||||
quote: comment.get('quote') as Comment['quote'],
|
||||
author: comment.get('author') as Comment['author'],
|
||||
text: comment.get('text') as Comment['text'],
|
||||
};
|
||||
comments.push(result);
|
||||
});
|
||||
return comments;
|
||||
}
|
||||
|
||||
parseTextSelection(selection: TextSelection): {
|
||||
quote: CommentContent['quote'];
|
||||
range: CommentRange;
|
||||
} | null {
|
||||
const [_, ctx] = this._command
|
||||
.chain()
|
||||
.getSelectedBlocks({
|
||||
currentTextSelection: selection,
|
||||
types: ['text'],
|
||||
})
|
||||
.run();
|
||||
const blocks = ctx.selectedBlocks;
|
||||
if (!blocks || blocks.length === 0) return null;
|
||||
|
||||
const { from, to } = selection;
|
||||
const fromBlock = blocks[0];
|
||||
const fromBlockText = fromBlock.model.text;
|
||||
const fromBlockId = fromBlock.model.id;
|
||||
const toBlock = blocks[blocks.length - 1];
|
||||
const toBlockText = toBlock.model.text;
|
||||
const toBlockId = toBlock.model.id;
|
||||
if (!fromBlockText || !toBlockText) return null;
|
||||
|
||||
const startIndex = DocCollection.Y.createRelativePositionFromTypeIndex(
|
||||
fromBlockText.yText,
|
||||
from.index
|
||||
);
|
||||
const endIndex = DocCollection.Y.createRelativePositionFromTypeIndex(
|
||||
toBlockText.yText,
|
||||
to ? to.index + to.length : from.index + from.length
|
||||
);
|
||||
const quote = blocks.reduce((acc, block, index) => {
|
||||
const text = block.model.text;
|
||||
if (!text) return acc;
|
||||
|
||||
if (index === 0) {
|
||||
return (
|
||||
acc +
|
||||
text.yText.toString().slice(from.index, from.index + from.length)
|
||||
);
|
||||
}
|
||||
if (index === blocks.length - 1 && to) {
|
||||
return acc + ' ' + text.yText.toString().slice(0, to.index + to.length);
|
||||
}
|
||||
|
||||
return acc + ' ' + text.yText.toString();
|
||||
}, '');
|
||||
|
||||
return {
|
||||
quote,
|
||||
range: {
|
||||
start: {
|
||||
id: fromBlockId,
|
||||
index: startIndex,
|
||||
},
|
||||
end: {
|
||||
id: toBlockId,
|
||||
index: endIndex,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
import type { AffineEditorContainer } from '../../editors/editor-container.js';
|
||||
import { CommentInput } from './comment-input.js';
|
||||
import { CommentManager } from './comment-manager.js';
|
||||
|
||||
export class CommentPanel extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
comment-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: 1px solid var(--affine-border-color, #e3e2e4);
|
||||
background-color: var(--affine-background-primary-color);
|
||||
height: 100vh;
|
||||
width: 320px;
|
||||
box-sizing: border-box;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.comment-panel-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.comment-panel-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-panel-comments {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.comment-panel-comment {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comment-panel-comment-quote {
|
||||
font-size: 10px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--affine-text-secondary-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-panel-comment-author {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-panel-comment-text {
|
||||
margin-top: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
commentManager: CommentManager | null = null;
|
||||
|
||||
private _addComment() {
|
||||
const textSelection = this.editor.host?.selection.find('text');
|
||||
if (!textSelection) return;
|
||||
|
||||
const commentInput = new CommentInput();
|
||||
if (!this.commentManager) return;
|
||||
|
||||
commentInput.manager = this.commentManager;
|
||||
commentInput.onSubmit = () => {
|
||||
this.requestUpdate();
|
||||
};
|
||||
this._container.append(commentInput);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.editor.host) return;
|
||||
this.commentManager = new CommentManager(this.editor.host);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.commentManager) return;
|
||||
const comments = this.commentManager.getComments();
|
||||
|
||||
return html`<div class="comment-panel-container">
|
||||
<div class="comment-panel-head">
|
||||
<button @click=${this._addComment}>Add Comment</button>
|
||||
</div>
|
||||
<div class="comment-panel-comments">
|
||||
${comments.map(comment => {
|
||||
return html`<div class="comment-panel-comment">
|
||||
<div class="comment-panel-comment-quote">${comment.quote}</div>
|
||||
<div class="comment-panel-comment-author">${comment.author}</div>
|
||||
<div class="comment-panel-comment-text">
|
||||
<rich-text .yText=${comment.text} .readonly=${true}></rich-text>
|
||||
</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@query('.comment-panel-container')
|
||||
private accessor _container!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: AffineEditorContainer;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'comment-panel': CommentPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './comment-manager.js';
|
||||
export * from './comment-panel.js';
|
||||
@@ -0,0 +1,148 @@
|
||||
import { DualLinkIcon16, scrollbarStyle } from '@blocksuite/blocks';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, unsafeCSS } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
|
||||
import { type BacklinkData, DEFAULT_DOC_NAME } from './utils.js';
|
||||
|
||||
export class BacklinkButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
user-select: none;
|
||||
font-size: var(--affine-font-sm);
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
fill: var(--affine-text-secondary-color);
|
||||
color: var(--affine-text-secondary-color);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.btn > span {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.backlink-popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
transform: translateY(100%);
|
||||
z-index: 1;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 4px;
|
||||
background: var(--affine-white);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.backlink-popover .group-title {
|
||||
color: var(--affine-text-secondary-color);
|
||||
margin: 8px 12px;
|
||||
}
|
||||
|
||||
.backlink-popover icon-button {
|
||||
padding: 8px;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
${scrollbarStyle('.backlink-popover .group')}
|
||||
`;
|
||||
|
||||
private _backlinks: BacklinkData[];
|
||||
|
||||
// Handle click outside
|
||||
private _onClickAway = (e: Event) => {
|
||||
if (e.target === this) return;
|
||||
if (!this._showPopover) return;
|
||||
this._showPopover = false;
|
||||
document.removeEventListener('mousedown', this._onClickAway);
|
||||
};
|
||||
|
||||
constructor(backlinks: BacklinkData[]) {
|
||||
super();
|
||||
|
||||
this._backlinks = backlinks;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.tabIndex = 0;
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this._showPopover = !this._showPopover;
|
||||
document.addEventListener('mousedown', this._onClickAway);
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Only show linked doc backlinks
|
||||
const backlinks = this._backlinks;
|
||||
if (!backlinks.length) return null;
|
||||
|
||||
return html`
|
||||
<div class="btn" @click="${this.onClick}">
|
||||
${DualLinkIcon16}<span>Backlinks (${backlinks.length})</span>
|
||||
${this._showPopover ? backlinkPopover(backlinks) : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _showPopover = false;
|
||||
}
|
||||
|
||||
function backlinkPopover(backlinks: BacklinkData[]) {
|
||||
return html`<div
|
||||
class="backlink-popover"
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div class="menu">
|
||||
<div class="group-title">Linked to this doc</div>
|
||||
<div class="group" style="overflow-y: scroll; max-height: 372px;">
|
||||
${backlinks.map(link => {
|
||||
const title = link.title || DEFAULT_DOC_NAME;
|
||||
return html`<icon-button
|
||||
width="248px"
|
||||
height="32px"
|
||||
text="${title}"
|
||||
@mousedown="${link.jump}"
|
||||
>
|
||||
${link.icon}
|
||||
</icon-button>`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export const DOC_BLOCK_CHILD_PADDING = 24;
|
||||
|
||||
export const DEFAULT_DOC_NAME = 'Untitled';
|
||||
|
||||
export type BackLink = {
|
||||
pageId: string;
|
||||
blockId: string;
|
||||
type: NonNullable<AffineTextAttributes['reference']>['type'];
|
||||
};
|
||||
|
||||
export type BacklinkData = BackLink &
|
||||
DocMeta & {
|
||||
jump: () => void;
|
||||
icon: TemplateResult;
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import type { RichText, RootBlockModel } from '@blocksuite/blocks';
|
||||
import { assertExists, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
const DOC_BLOCK_CHILD_PADDING = 24;
|
||||
|
||||
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.doc-title-container {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 40px;
|
||||
line-height: 50px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
resize: none;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
max-width: var(--affine-editor-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 38px 0;
|
||||
|
||||
padding-left: var(
|
||||
--affine-editor-side-padding,
|
||||
${DOC_BLOCK_CHILD_PADDING}px
|
||||
);
|
||||
padding-right: var(
|
||||
--affine-editor-side-padding,
|
||||
${DOC_BLOCK_CHILD_PADDING}px
|
||||
);
|
||||
}
|
||||
|
||||
/* Extra small devices (phones, 640px and down) */
|
||||
@container viewport (width <= 640px) {
|
||||
.doc-title-container {
|
||||
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-title-container-empty::before {
|
||||
content: 'Title';
|
||||
color: var(--affine-placeholder-color);
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.doc-title-container:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
private _onTitleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing || this.doc.readonly) return;
|
||||
const hasContent = !this.doc.isEmpty;
|
||||
|
||||
if (event.key === 'Enter' && hasContent && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const inlineEditor = this._inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (inlineRange) {
|
||||
const rightText = this._rootModel.title.split(inlineRange.index);
|
||||
this._pageRoot.prependParagraphWithText(rightText);
|
||||
}
|
||||
} else if (event.key === 'ArrowDown' && hasContent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this._pageRoot.focusFirstParagraph();
|
||||
} else if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
private _updateTitleInMeta = () => {
|
||||
this.doc.collection.setDocMeta(this.doc.id, {
|
||||
title: this._rootModel.title.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
private get _inlineEditor() {
|
||||
return this._richTextElement.inlineEditor;
|
||||
}
|
||||
|
||||
private get _pageRoot() {
|
||||
const pageRoot = this._viewport.querySelector('affine-page-root');
|
||||
assertExists(pageRoot);
|
||||
return pageRoot;
|
||||
}
|
||||
|
||||
private get _rootModel() {
|
||||
return this.doc.root as RootBlockModel;
|
||||
}
|
||||
|
||||
private get _viewport() {
|
||||
const el = this.closest<HTMLElement>('.affine-page-viewport');
|
||||
assertExists(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._isReadonly = this.doc.readonly;
|
||||
this._disposables.add(
|
||||
this.doc.awarenessStore.slots.update.on(() => {
|
||||
if (this._isReadonly !== this.doc.readonly) {
|
||||
this._isReadonly = this.doc.readonly;
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.addFromEvent(this, 'keydown', this._onTitleKeyDown);
|
||||
|
||||
// Workaround for inline editor skips composition event
|
||||
this._disposables.addFromEvent(
|
||||
this,
|
||||
'compositionstart',
|
||||
() => (this._isComposing = true)
|
||||
);
|
||||
|
||||
this._disposables.addFromEvent(
|
||||
this,
|
||||
'compositionend',
|
||||
() => (this._isComposing = false)
|
||||
);
|
||||
|
||||
const updateMetaTitle = () => {
|
||||
this._updateTitleInMeta();
|
||||
this.requestUpdate();
|
||||
};
|
||||
this._rootModel.title.yText.observe(updateMetaTitle);
|
||||
this._disposables.add(() => {
|
||||
this._rootModel.title.yText.unobserve(updateMetaTitle);
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isEmpty = !this._rootModel.title.length && !this._isComposing;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="doc-title-container ${isEmpty
|
||||
? 'doc-title-container-empty'
|
||||
: ''}"
|
||||
data-block-is-title="true"
|
||||
>
|
||||
<rich-text
|
||||
.yText=${this._rootModel.title.yText}
|
||||
.undoManager=${this.doc.history}
|
||||
.verticalScrollContainerGetter=${() => this._viewport}
|
||||
.readonly=${this.doc.readonly}
|
||||
.enableFormat=${false}
|
||||
></rich-text>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _isComposing = false;
|
||||
|
||||
@state()
|
||||
private accessor _isReadonly = false;
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement!: RichText;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Doc;
|
||||
}
|
||||
|
||||
export function getDocTitleByEditorHost(
|
||||
editorHost: EditorHost
|
||||
): DocTitle | null {
|
||||
const docViewport = editorHost.closest('.affine-page-viewport');
|
||||
if (!docViewport) return null;
|
||||
return docViewport.querySelector('doc-title');
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'doc-title': DocTitle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { generateKeyBetweenV2 } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
DocModeProvider,
|
||||
EdgelessFrameManager,
|
||||
EdgelessRootService,
|
||||
EditPropsStore,
|
||||
type FrameBlockModel,
|
||||
} from '@blocksuite/blocks';
|
||||
import {
|
||||
Bound,
|
||||
DisposableGroup,
|
||||
SignalWatcher,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type {
|
||||
DragEvent,
|
||||
FitViewEvent,
|
||||
FrameCard,
|
||||
SelectEvent,
|
||||
} from '../card/frame-card.js';
|
||||
import { startDragging } from '../utils/drag.js';
|
||||
|
||||
const compare = EdgelessFrameManager.framePresentationComparator;
|
||||
|
||||
type FrameListItem = {
|
||||
frame: FrameBlockModel;
|
||||
|
||||
// frame index
|
||||
frameIndex: string;
|
||||
|
||||
// card index
|
||||
cardIndex: number;
|
||||
};
|
||||
|
||||
const styles = css`
|
||||
.frame-list-container {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.no-frame-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.no-frame-placeholder {
|
||||
margin-top: 240px;
|
||||
align-self: center;
|
||||
width: 230px;
|
||||
height: 48px;
|
||||
color: var(--affine-text-secondary-color, #8e8d91);
|
||||
text-align: center;
|
||||
|
||||
/* light/base */
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.insert-indicator {
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background-color: var(--affine-blue-600);
|
||||
position: absolute;
|
||||
contain: layout size;
|
||||
width: 284px;
|
||||
left: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_PANEL_BODY = 'affine-frame-panel-body';
|
||||
|
||||
export class FramePanelBody extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = styles;
|
||||
|
||||
private _clearDocDisposables = () => {
|
||||
this._docDisposables?.dispose();
|
||||
this._docDisposables = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* click at blank area to clear selection
|
||||
*/
|
||||
private _clickBlank = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// check if click at frame-card, if not, set this._selected to empty
|
||||
if (
|
||||
(e.target as HTMLElement).closest('frame-card') ||
|
||||
this._selected.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._selected = [];
|
||||
this._edgelessRootService?.selection.set({
|
||||
elements: this._selected,
|
||||
editing: false,
|
||||
});
|
||||
};
|
||||
|
||||
private _docDisposables: DisposableGroup | null = null;
|
||||
|
||||
private _frameElementHeight = 0;
|
||||
|
||||
private _frameItems: FrameListItem[] = [];
|
||||
|
||||
private _indicatorTranslateY = 0;
|
||||
|
||||
private _lastEdgelessRootId = '';
|
||||
|
||||
private _selectFrame = (e: SelectEvent) => {
|
||||
const { selected, id, multiselect } = e.detail;
|
||||
|
||||
if (!selected) {
|
||||
// de-select frame
|
||||
this._selected = this._selected.filter(frameId => frameId !== id);
|
||||
} else if (multiselect) {
|
||||
this._selected = [...this._selected, id];
|
||||
} else {
|
||||
this._selected = [id];
|
||||
}
|
||||
|
||||
this._edgelessRootService?.selection.set({
|
||||
elements: this._selected,
|
||||
editing: false,
|
||||
});
|
||||
};
|
||||
|
||||
private _updateFrameItems = () => {
|
||||
this._frameItems = this.frames.map((frame, idx) => ({
|
||||
frame,
|
||||
frameIndex: frame.presentationIndex ?? frame.index,
|
||||
cardIndex: idx,
|
||||
}));
|
||||
};
|
||||
|
||||
get _edgelessRootService() {
|
||||
return this.editorHost.std.getOptional(EdgelessRootService);
|
||||
}
|
||||
|
||||
get frames() {
|
||||
const frames = this.editorHost.doc
|
||||
.getBlocksByFlavour('affine:frame')
|
||||
.map(block => block.model as FrameBlockModel);
|
||||
return frames.sort(compare);
|
||||
}
|
||||
|
||||
get viewportPadding(): [number, number, number, number] {
|
||||
return this.fitPadding
|
||||
? ([0, 0, 0, 0].map((val, idx) =>
|
||||
Number.isFinite(this.fitPadding[idx]) ? this.fitPadding[idx] : val
|
||||
) as [number, number, number, number])
|
||||
: [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
private _drag(e: DragEvent) {
|
||||
if (!this._selected.length) return;
|
||||
|
||||
this._dragging = true;
|
||||
|
||||
const framesMap = this._frameItems.reduce((map, frame) => {
|
||||
map.set(frame.frame.id, {
|
||||
...frame,
|
||||
});
|
||||
return map;
|
||||
}, new Map<string, FrameListItem>());
|
||||
const selected = this._selected.slice();
|
||||
|
||||
const draggedFramesInfo = selected.map(id => {
|
||||
const frame = framesMap.get(id) as FrameListItem;
|
||||
|
||||
return {
|
||||
frame: frame.frame,
|
||||
element: this.renderRoot.querySelector(
|
||||
`[data-frame-id="${frame.frame.id}"]`
|
||||
) as FrameCard,
|
||||
cardIndex: frame.cardIndex,
|
||||
frameIndex: frame.frameIndex,
|
||||
};
|
||||
});
|
||||
const width = draggedFramesInfo[0].element.clientWidth;
|
||||
|
||||
this._frameElementHeight = draggedFramesInfo[0].element.offsetHeight;
|
||||
|
||||
startDragging(draggedFramesInfo, {
|
||||
width,
|
||||
container: this,
|
||||
document: this.ownerDocument,
|
||||
domHost: this.domHost ?? this.ownerDocument,
|
||||
start: {
|
||||
x: e.detail.clientX,
|
||||
y: e.detail.clientY,
|
||||
},
|
||||
framePanelBody: this,
|
||||
frameListContainer: this.frameListContainer,
|
||||
frameElementHeight: this._frameElementHeight,
|
||||
onDragEnd: insertIdx => {
|
||||
this._dragging = false;
|
||||
this.insertIndex = undefined;
|
||||
|
||||
if (insertIdx === undefined || this._frameItems.length <= 1) return;
|
||||
this._reorderFrames(selected, framesMap, insertIdx);
|
||||
},
|
||||
onDragMove: (idx, indicatorTranslateY) => {
|
||||
this.insertIndex = idx;
|
||||
this._indicatorTranslateY = indicatorTranslateY ?? 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _fitToElement(e: FitViewEvent) {
|
||||
const { block } = e.detail;
|
||||
const bound = Bound.deserialize(block.xywh);
|
||||
|
||||
if (!this._edgelessRootService) {
|
||||
// When click frame card in page mode
|
||||
// Should switch to edgeless mode and set viewport to the frame
|
||||
const viewport = {
|
||||
xywh: block.xywh,
|
||||
referenceId: block.id,
|
||||
padding: this.viewportPadding as [number, number, number, number],
|
||||
};
|
||||
|
||||
this.editorHost.std.get(EditPropsStore).setStorage('viewport', viewport);
|
||||
this.editorHost.std.get(DocModeProvider).setEditorMode('edgeless');
|
||||
} else {
|
||||
this._edgelessRootService.viewport.setViewportByBound(
|
||||
bound,
|
||||
this.viewportPadding,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderEmptyContent() {
|
||||
const emptyContent = html` <div class="no-frame-container">
|
||||
<div class="no-frame-placeholder">
|
||||
Add frames to organize and present your Edgeless
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return emptyContent;
|
||||
}
|
||||
|
||||
private _renderFrameList() {
|
||||
const selectedFrames = new Set(this._selected);
|
||||
const frameCards = html`${repeat(this._frameItems, frameItem => {
|
||||
const { frame, frameIndex, cardIndex } = frameItem;
|
||||
return keyed(
|
||||
frame,
|
||||
html`<affine-frame-card
|
||||
data-frame-id=${frame.id}
|
||||
.frame=${frame}
|
||||
.cardIndex=${cardIndex}
|
||||
.frameIndex=${frameIndex}
|
||||
.status=${selectedFrames.has(frame.id)
|
||||
? this._dragging
|
||||
? 'placeholder'
|
||||
: 'selected'
|
||||
: 'none'}
|
||||
@select=${this._selectFrame}
|
||||
@fitview=${this._fitToElement}
|
||||
@drag=${this._drag}
|
||||
></affine-frame-card>`
|
||||
);
|
||||
})}`;
|
||||
|
||||
const frameList = html` <div class="frame-list-container">
|
||||
${this.insertIndex !== undefined
|
||||
? html`<div
|
||||
class="insert-indicator"
|
||||
style=${`transform: translateY(${this._indicatorTranslateY}px)`}
|
||||
></div>`
|
||||
: nothing}
|
||||
${frameCards}
|
||||
</div>`;
|
||||
return frameList;
|
||||
}
|
||||
|
||||
private _reorderFrames(
|
||||
selected: string[],
|
||||
framesMap: Map<string, FrameListItem>,
|
||||
insertIndex: number
|
||||
) {
|
||||
if (insertIndex >= 0 && insertIndex <= this._frameItems.length) {
|
||||
const frames = Array.from(framesMap.values()).map(
|
||||
frameItem => frameItem.frame
|
||||
);
|
||||
const selectedFrames = selected
|
||||
.map(id => framesMap.get(id) as FrameListItem)
|
||||
.map(frameItem => frameItem.frame)
|
||||
.sort(compare);
|
||||
|
||||
// update selected frames index
|
||||
// make the indexes larger than the frame before and smaller than the frame after
|
||||
let before = frames[insertIndex - 1]?.presentationIndex || null;
|
||||
const after = frames[insertIndex]?.presentationIndex || null;
|
||||
selectedFrames.forEach(frame => {
|
||||
const newIndex = generateKeyBetweenV2(before, after);
|
||||
frame.doc.updateBlock(frame, {
|
||||
presentationIndex: newIndex,
|
||||
});
|
||||
before = newIndex;
|
||||
});
|
||||
|
||||
this.editorHost.doc.captureSync();
|
||||
this._updateFrames();
|
||||
}
|
||||
}
|
||||
|
||||
private _setDocDisposables(doc: Doc) {
|
||||
this._clearDocDisposables();
|
||||
this._docDisposables = new DisposableGroup();
|
||||
this._docDisposables.add(
|
||||
doc.slots.blockUpdated.on(({ type, flavour }) => {
|
||||
if (flavour === 'affine:frame' && type !== 'update') {
|
||||
requestAnimationFrame(() => {
|
||||
this._updateFrames();
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _updateFrames() {
|
||||
if (this._dragging) return;
|
||||
|
||||
if (!this.frames.length) {
|
||||
this._selected = [];
|
||||
this._frameItems = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const frameItems: FramePanelBody['_frameItems'] = [];
|
||||
const oldSelectedSet = new Set(this._selected);
|
||||
const newSelected: string[] = [];
|
||||
const frames = this.frames.sort(compare);
|
||||
frames.forEach((frame, idx) => {
|
||||
const frameItem = {
|
||||
frame,
|
||||
frameIndex: frame.presentationIndex ?? frame.index,
|
||||
cardIndex: idx,
|
||||
};
|
||||
|
||||
frameItems.push(frameItem);
|
||||
if (oldSelectedSet.has(frame.id)) {
|
||||
newSelected.push(frame.id);
|
||||
}
|
||||
});
|
||||
|
||||
this._frameItems = frameItems;
|
||||
this._selected = newSelected;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._updateFrameItems();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._clearDocDisposables();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const disposables = this.disposables;
|
||||
disposables.addFromEvent(this, 'click', this._clickBlank);
|
||||
}
|
||||
|
||||
override render() {
|
||||
this._updateFrameItems();
|
||||
return html` ${this._frameItems.length
|
||||
? this._renderFrameList()
|
||||
: this._renderEmptyContent()}`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('editorHost') && this.editorHost) {
|
||||
this._setDocDisposables(this.editorHost.doc);
|
||||
// after switch to edgeless mode, should update the selection
|
||||
if (this.editorHost.doc.id === this._lastEdgelessRootId) {
|
||||
this._edgelessRootService?.selection.set({
|
||||
elements: this._selected,
|
||||
editing: false,
|
||||
});
|
||||
} else {
|
||||
this._selected = this._selected.length ? [] : this._selected;
|
||||
}
|
||||
this._lastEdgelessRootId = this.editorHost.doc.id;
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _dragging = false;
|
||||
|
||||
// Store the ids of the selected frames
|
||||
@state()
|
||||
private accessor _selected: string[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor domHost!: Document | HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editorHost!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fitPadding!: number[];
|
||||
|
||||
@query('.frame-list-container')
|
||||
accessor frameListContainer!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor insertIndex: number | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_PANEL_BODY]: FramePanelBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import type { FrameBlockModel, RichText } from '@blocksuite/blocks';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
const styles = css`
|
||||
frame-card-title-editor rich-text .nowrap-lines::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_TITLE_EDITOR = 'affine-frame-card-title-editor';
|
||||
|
||||
export class FrameCardTitleEditor extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _isComposing = false;
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
private _unmount() {
|
||||
// dispose in advance to avoid execute `this.remove()` twice
|
||||
this.disposables.dispose();
|
||||
this.remove();
|
||||
this.titleContentElement.style.display = 'block';
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (this.inlineEditor === null) return;
|
||||
|
||||
this.titleContentElement.style.display = 'none';
|
||||
|
||||
this.inlineEditor.selectAll();
|
||||
|
||||
this.inlineEditor.slots.renderComplete.on(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
const inlineEditorContainer = this.inlineEditor.rootElement;
|
||||
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'blur', () => {
|
||||
this._unmount();
|
||||
});
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'click', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'dblclick', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'keydown', e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && !this._isComposing) {
|
||||
this._unmount();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.richText?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const inlineEditorStyle = styleMap({
|
||||
transformOrigin: 'top left',
|
||||
borderRadius: '4px',
|
||||
maxWidth: `${this.maxWidth}px`,
|
||||
maxHeight: '20px',
|
||||
width: 'fit-content',
|
||||
height: '20px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
lineHeight: '20px',
|
||||
position: 'absolute',
|
||||
left: `${this.left}px`,
|
||||
top: '0px',
|
||||
minWidth: '8px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
border: '1px solid var(--affine-primary-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
boxShadow: '0px 0px 0px 2px rgba(30, 150, 235, 0.30)',
|
||||
zIndex: '1',
|
||||
display: 'block',
|
||||
});
|
||||
return html`<rich-text
|
||||
.yText=${this.frameModel.title.yText}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${true}
|
||||
.enableUndoRedo=${false}
|
||||
.wrapText=${false}
|
||||
style=${inlineEditorStyle}
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frameModel!: FrameBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor left!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor maxWidth!: number;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor titleContentElement!: HTMLElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_TITLE_EDITOR]: FrameCardTitleEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import type { FrameBlockModel } from '@blocksuite/blocks';
|
||||
import { DisposableGroup, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { Y } from '@blocksuite/store';
|
||||
import { css, html, type PropertyValues } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
import { FrameCardTitleEditor } from './frame-card-title-editor.js';
|
||||
|
||||
const styles = css`
|
||||
.frame-card-title-container {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
gap: 6px;
|
||||
font-size: var(--affine-font-sm);
|
||||
cursor: default;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-card-title-container .card-index {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
background: var(--affine-black);
|
||||
margin-left: 2px;
|
||||
|
||||
color: var(--affine-white);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.frame-card-title-container .card-title {
|
||||
height: 20px;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_CARD_TITLE = 'affine-frame-card-title';
|
||||
|
||||
export class FrameCardTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _clearTitleDisposables = () => {
|
||||
this._titleDisposables?.dispose();
|
||||
this._titleDisposables = null;
|
||||
};
|
||||
|
||||
private _mountTitleEditor = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const titleEditor = new FrameCardTitleEditor();
|
||||
titleEditor.frameModel = this.frame;
|
||||
titleEditor.titleContentElement = this.titleContentElement;
|
||||
const left = this.titleIndexElement.offsetWidth + 6;
|
||||
titleEditor.left = left;
|
||||
titleEditor.maxWidth = this.titleContainer.offsetWidth - left - 6;
|
||||
this.titleContainer.append(titleEditor);
|
||||
};
|
||||
|
||||
private _titleDisposables: DisposableGroup | null = null;
|
||||
|
||||
private _updateElement = () => {
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private _setFrameDisposables(title: Y.Text) {
|
||||
this._clearTitleDisposables();
|
||||
title.observe(this._updateElement);
|
||||
this._titleDisposables = new DisposableGroup();
|
||||
this._titleDisposables.add({
|
||||
dispose: () => {
|
||||
title.unobserve(this._updateElement);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._clearTitleDisposables();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class="frame-card-title-container">
|
||||
<div
|
||||
class="card-index"
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
@dblclick=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
${this.cardIndex + 1}
|
||||
</div>
|
||||
<div class="card-title">
|
||||
<span
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
@dblclick=${this._mountTitleEditor}
|
||||
>${this.frame.title}</span
|
||||
>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('frame')) {
|
||||
this._setFrameDisposables(this.frame.title.yText);
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor cardIndex!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frame!: FrameBlockModel;
|
||||
|
||||
@query('.frame-card-title-container')
|
||||
accessor titleContainer!: HTMLElement;
|
||||
|
||||
@query('.frame-card-title-container .card-title')
|
||||
accessor titleContentElement!: HTMLElement;
|
||||
|
||||
@query('.frame-card-title-container .card-index')
|
||||
accessor titleIndexElement!: HTMLElement;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_CARD_TITLE]: FrameCardTitle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { type FrameBlockModel, on, once } from '@blocksuite/blocks';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export type ReorderEvent = CustomEvent<{
|
||||
currentNumber: number;
|
||||
targetNumber: number;
|
||||
realIndex: number;
|
||||
}>;
|
||||
|
||||
export type SelectEvent = CustomEvent<{
|
||||
id: string;
|
||||
selected: boolean;
|
||||
index: number;
|
||||
multiselect: boolean;
|
||||
}>;
|
||||
|
||||
export type DragEvent = CustomEvent<{
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}>;
|
||||
|
||||
export type FitViewEvent = CustomEvent<{
|
||||
block: FrameBlockModel;
|
||||
}>;
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.frame-card-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 284px;
|
||||
height: 198px;
|
||||
gap: 8px;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-card-body {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background: var(--affine-background-primary-color);
|
||||
box-shadow: 0px 0px 12px 0px rgba(66, 65, 73, 0.18);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-card-container.selected .frame-card-body {
|
||||
border: 2px solid var(--light-brand-color, #1e96eb);
|
||||
}
|
||||
|
||||
.frame-card-container.dragging {
|
||||
pointer-events: none;
|
||||
transform-origin: 16px 8px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: calc(var(--affine-z-index-popover, 0) + 3);
|
||||
}
|
||||
|
||||
.frame-card-container.dragging frame-card-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.frame-card-container.dragging .dragging-card-number {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translate(-30%, 30%);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--affine-black);
|
||||
color: var(--affine-white);
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.frame-card-container.placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_CARD = 'affine-frame-card';
|
||||
|
||||
export class FrameCard extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _dispatchDragEvent(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const { clientX: startX, clientY: startY } = e;
|
||||
const disposeDragStart = on(this.ownerDocument, 'mousemove', e => {
|
||||
if (
|
||||
Math.abs(startX - e.clientX) < 5 &&
|
||||
Math.abs(startY - e.clientY) < 5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.status !== 'selected') {
|
||||
this._dispatchSelectEvent(e);
|
||||
}
|
||||
|
||||
const event = new CustomEvent('drag', {
|
||||
detail: {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
pageX: e.pageX,
|
||||
pageY: e.pageY,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
disposeDragStart();
|
||||
});
|
||||
|
||||
once(this.ownerDocument, 'mouseup', () => {
|
||||
disposeDragStart();
|
||||
});
|
||||
}
|
||||
|
||||
private _dispatchFitViewEvent(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
|
||||
const event = new CustomEvent('fitview', {
|
||||
detail: {
|
||||
block: this.frame,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _dispatchSelectEvent(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const event = new CustomEvent('select', {
|
||||
detail: {
|
||||
id: this.frame.id,
|
||||
selected: this.status !== 'selected',
|
||||
index: this.cardIndex,
|
||||
multiselect: e.shiftKey,
|
||||
},
|
||||
}) as SelectEvent;
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _DraggingCardNumber() {
|
||||
if (this.draggingCardNumber === undefined) return nothing;
|
||||
|
||||
return html`<div class="dragging-card-number">
|
||||
${this.draggingCardNumber}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { pos, stackOrder, width } = this;
|
||||
const containerStyle =
|
||||
this.status === 'dragging'
|
||||
? styleMap({
|
||||
transform: `${
|
||||
stackOrder === 0
|
||||
? `translate(${pos.x - 16}px, ${pos.y - 8}px)`
|
||||
: `translate(${pos.x - 10}px, ${pos.y - 16}px) scale(0.96)`
|
||||
}`,
|
||||
width: width ? `${width}px` : undefined,
|
||||
})
|
||||
: {};
|
||||
|
||||
return html`<div
|
||||
class="frame-card-container ${this.status ?? ''}"
|
||||
style=${containerStyle}
|
||||
>
|
||||
${this.status === 'dragging'
|
||||
? nothing
|
||||
: html`<affine-frame-card-title
|
||||
.cardIndex=${this.cardIndex}
|
||||
.frame=${this.frame}
|
||||
></affine-frame-card-title>`}
|
||||
<div
|
||||
class="frame-card-body"
|
||||
@click=${this._dispatchSelectEvent}
|
||||
@dblclick=${this._dispatchFitViewEvent}
|
||||
@mousedown=${this._dispatchDragEvent}
|
||||
>
|
||||
${this.status === 'dragging' && stackOrder !== 0
|
||||
? nothing
|
||||
: html`<frame-preview .frame=${this.frame}></frame-preview>`}
|
||||
${this._DraggingCardNumber()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor cardIndex!: number;
|
||||
|
||||
@query('.frame-card-container')
|
||||
accessor containerElement!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor draggingCardNumber: number | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frame!: FrameBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor frameIndex!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor pos!: { x: number; y: number };
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor stackOrder!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor status: 'selected' | 'dragging' | 'placeholder' | 'none' = 'none';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width: number | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_CARD]: FrameCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
const styles = css`
|
||||
frame-panel {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.frame-panel-container {
|
||||
background-color: var(--affine-background-primary-color);
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
height: 100%;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.frame-panel-body {
|
||||
padding-top: 12px;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin; /* For Firefox */
|
||||
scrollbar-color: transparent transparent; /* For Firefox */
|
||||
}
|
||||
|
||||
.frame-panel-body::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.frame-panel-body::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.frame-panel-body:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--affine-black-30);
|
||||
}
|
||||
|
||||
.frame-panel-body::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.frame-panel-body::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_PANEL = 'affine-frame-panel';
|
||||
|
||||
export class FramePanel extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
override render() {
|
||||
return html`<div class="frame-panel-container">
|
||||
<affine-frame-panel-header
|
||||
.editorHost=${this.host}
|
||||
></affine-frame-panel-header>
|
||||
<affine-frame-panel-body
|
||||
class="frame-panel-body"
|
||||
.editorHost=${this.host}
|
||||
.fitPadding=${this.fitPadding}
|
||||
></affine-frame-panel-body>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fitPadding: number[] = [50, 380, 50, 50];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host!: EditorHost;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_PANEL]: FramePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import {
|
||||
createButtonPopper,
|
||||
DocModeProvider,
|
||||
EdgelessRootService,
|
||||
EditPropsStore,
|
||||
type NavigatorMode,
|
||||
} from '@blocksuite/blocks';
|
||||
import { DisposableGroup, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import { SettingsIcon, SmallFrameNavigatorIcon } from '../../_common/icons.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.frame-panel-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.all-frames-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100px;
|
||||
height: 24px;
|
||||
margin: 8px 4px;
|
||||
}
|
||||
|
||||
.all-frames-setting-button svg {
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.all-frames-setting-button:hover svg,
|
||||
.all-frames-setting-button.active svg {
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.all-frames-setting-label {
|
||||
width: 68px;
|
||||
height: 22px;
|
||||
font-size: var(--affine-font-sm);
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: var(--light-text-color-text-secondary-color, #8e8d91);
|
||||
}
|
||||
|
||||
.frames-setting-container {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.frames-setting-container[data-show] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.presentation-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
width: 117px;
|
||||
height: 28px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
margin: 4px 0;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
background: var(--affine-white);
|
||||
}
|
||||
|
||||
.presentation-button:hover {
|
||||
background: var(--affine-hover-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.presentation-button svg {
|
||||
fill: var(--affine-icon-color);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.presentation-button-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAME_PANEL_HEADER = 'affine-frame-panel-header';
|
||||
|
||||
export class FramePanelHeader extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _clearEdgelessDisposables = () => {
|
||||
this._edgelessDisposables?.dispose();
|
||||
this._edgelessDisposables = null;
|
||||
};
|
||||
|
||||
private _edgelessDisposables: DisposableGroup | null = null;
|
||||
|
||||
private _enterPresentationMode = () => {
|
||||
if (!this._edgelessRootService) {
|
||||
this.editorHost.std.get(DocModeProvider).setEditorMode('edgeless');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this._edgelessRootService?.gfx.tool.setTool({
|
||||
type: 'frameNavigator',
|
||||
mode: this._navigatorMode,
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
private _framesSettingMenuPopper: ReturnType<
|
||||
typeof createButtonPopper
|
||||
> | null = null;
|
||||
|
||||
private _navigatorMode: NavigatorMode = 'fit';
|
||||
|
||||
private _setEdgelessDisposables = () => {
|
||||
if (!this._edgelessRootService) return;
|
||||
|
||||
this._clearEdgelessDisposables();
|
||||
this._edgelessDisposables = new DisposableGroup();
|
||||
this._edgelessDisposables.add(
|
||||
this._edgelessRootService.slots.navigatorSettingUpdated.on(
|
||||
({ fillScreen }) => {
|
||||
this._navigatorMode = fillScreen ? 'fill' : 'fit';
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
private get _edgelessRootService() {
|
||||
return this.editorHost.std.getOptional(EdgelessRootService);
|
||||
}
|
||||
|
||||
private _tryLoadNavigatorStateLocalRecord() {
|
||||
this._navigatorMode = this.editorHost.std
|
||||
.get(EditPropsStore)
|
||||
.getStorage('presentFillScreen')
|
||||
? 'fill'
|
||||
: 'fit';
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._tryLoadNavigatorStateLocalRecord();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._edgelessDisposables) {
|
||||
this._clearEdgelessDisposables();
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
const disposables = this.disposables;
|
||||
|
||||
this._framesSettingMenuPopper = createButtonPopper(
|
||||
this._frameSettingButton,
|
||||
this._frameSettingMenu,
|
||||
({ display }) => {
|
||||
this._settingPopperShow = display === 'show';
|
||||
},
|
||||
{
|
||||
mainAxis: 14,
|
||||
crossAxis: -100,
|
||||
}
|
||||
);
|
||||
disposables.add(this._framesSettingMenuPopper);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class="frame-panel-header">
|
||||
<div class="all-frames-setting">
|
||||
<span class="all-frames-setting-label">All frames</span>
|
||||
<edgeless-tool-icon-button
|
||||
class="all-frames-setting-button ${this._settingPopperShow
|
||||
? 'active'
|
||||
: ''}"
|
||||
.tooltip=${this._settingPopperShow ? '' : 'All Frames Settings'}
|
||||
.tipPosition=${'top'}
|
||||
.active=${this._settingPopperShow}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => this._framesSettingMenuPopper?.toggle()}
|
||||
>
|
||||
${SettingsIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
<div class="frames-setting-container">
|
||||
<affine-frames-setting-menu
|
||||
.editorHost=${this.editorHost}
|
||||
></affine-frames-setting-menu>
|
||||
</div>
|
||||
<div class="presentation-button" @click=${this._enterPresentationMode}>
|
||||
${SmallFrameNavigatorIcon}<span class="presentation-button-label"
|
||||
>Presentation</span
|
||||
>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('editorHost')) {
|
||||
if (this._edgelessRootService) {
|
||||
this._setEdgelessDisposables();
|
||||
} else {
|
||||
this._clearEdgelessDisposables();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@query('.all-frames-setting-button')
|
||||
private accessor _frameSettingButton!: HTMLDivElement;
|
||||
|
||||
@query('.frames-setting-container')
|
||||
private accessor _frameSettingMenu!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
private accessor _settingPopperShow = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editorHost!: EditorHost;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAME_PANEL_HEADER]: FramePanelHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { EdgelessRootService, EditPropsStore } from '@blocksuite/blocks';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, type PropertyValues } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.frames-setting-menu-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.frames-setting-menu-item {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 4px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.frames-setting-menu-item .setting-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.frames-setting-menu-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
box-sizing: border-box;
|
||||
background: var(--affine-border-color);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.frames-setting-menu-item.action {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.frames-setting-menu-item .action-label {
|
||||
width: 138px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.frames-setting-menu-item .toggle-button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
menu-divider {
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_FRAMES_SETTING_MENU = 'affine-frames-setting-menu';
|
||||
|
||||
export class FramesSettingMenu extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _onBlackBackgroundChange = (checked: boolean) => {
|
||||
this.blackBackground = checked;
|
||||
this._edgelessRootService?.slots.navigatorSettingUpdated.emit({
|
||||
blackBackground: this.blackBackground,
|
||||
});
|
||||
};
|
||||
|
||||
private _onFillScreenChange = (checked: boolean) => {
|
||||
this.fillScreen = checked;
|
||||
this._edgelessRootService?.slots.navigatorSettingUpdated.emit({
|
||||
fillScreen: this.fillScreen,
|
||||
});
|
||||
this._editPropsStore.setStorage('presentFillScreen', this.fillScreen);
|
||||
};
|
||||
|
||||
private _onHideToolBarChange = (checked: boolean) => {
|
||||
this.hideToolbar = checked;
|
||||
this._edgelessRootService?.slots.navigatorSettingUpdated.emit({
|
||||
hideToolbar: this.hideToolbar,
|
||||
});
|
||||
this._editPropsStore.setStorage('presentHideToolbar', this.hideToolbar);
|
||||
};
|
||||
|
||||
private get _edgelessRootService() {
|
||||
return this.editorHost.std.getOptional(EdgelessRootService);
|
||||
}
|
||||
|
||||
private get _editPropsStore() {
|
||||
return this.editorHost.std.get(EditPropsStore);
|
||||
}
|
||||
|
||||
private _tryRestoreSettings() {
|
||||
const blackBackground = this._editPropsStore.getStorage(
|
||||
'presentBlackBackground'
|
||||
);
|
||||
|
||||
this.blackBackground = blackBackground ?? true;
|
||||
this.fillScreen =
|
||||
this._editPropsStore.getStorage('presentFillScreen') ?? false;
|
||||
this.hideToolbar =
|
||||
this._editPropsStore.getStorage('presentHideToolbar') ?? false;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._tryRestoreSettings();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div
|
||||
class="frames-setting-menu-container"
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div class="frames-setting-menu-item">
|
||||
<div class="setting-label">Preview Settings</div>
|
||||
</div>
|
||||
<div class="frames-setting-menu-item action">
|
||||
<div class="action-label">Fill Screen</div>
|
||||
<div class="toggle-button">
|
||||
<toggle-switch
|
||||
.on=${this.fillScreen}
|
||||
.onChange=${this._onFillScreenChange}
|
||||
></toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<menu-divider></menu-divider>
|
||||
|
||||
<div class="frames-setting-menu-item">
|
||||
<div class="setting-label">Playback Settings</div>
|
||||
</div>
|
||||
<div class="frames-setting-menu-item action">
|
||||
<div class="action-label">Dark background</div>
|
||||
<div class="toggle-button">
|
||||
<toggle-switch
|
||||
.on=${this.blackBackground}
|
||||
.onChange=${this._onBlackBackgroundChange}
|
||||
></toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="frames-setting-menu-item action">
|
||||
<div class="action-label">Hide toolbar</div>
|
||||
<div class="toggle-button">
|
||||
<toggle-switch
|
||||
.on=${this.hideToolbar}
|
||||
.onChange=${this._onHideToolBarChange}
|
||||
></toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override updated(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('editorHost')) {
|
||||
if (this._edgelessRootService) {
|
||||
this.disposables.add(
|
||||
this._edgelessRootService.slots.navigatorSettingUpdated.on(
|
||||
({ blackBackground, hideToolbar }) => {
|
||||
if (
|
||||
blackBackground !== undefined &&
|
||||
blackBackground !== this.blackBackground
|
||||
) {
|
||||
this.blackBackground = blackBackground;
|
||||
}
|
||||
|
||||
if (
|
||||
hideToolbar !== undefined &&
|
||||
hideToolbar !== this.hideToolbar
|
||||
) {
|
||||
this.hideToolbar = hideToolbar;
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor blackBackground = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editorHost!: EditorHost;
|
||||
|
||||
@state()
|
||||
accessor fillScreen = false;
|
||||
|
||||
@state()
|
||||
accessor hideToolbar = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_FRAMES_SETTING_MENU]: FramesSettingMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './frame-panel.js';
|
||||
@@ -0,0 +1,154 @@
|
||||
import { type FrameBlockModel, on, once } from '@blocksuite/blocks';
|
||||
|
||||
import type { FramePanelBody } from '../body/frame-panel-body.js';
|
||||
import { FrameCard } from '../card/frame-card.js';
|
||||
|
||||
/**
|
||||
* start drag frame cards
|
||||
* @param frames frames to drag
|
||||
*/
|
||||
export function startDragging(
|
||||
frames: {
|
||||
frame: FrameBlockModel;
|
||||
element: FrameCard;
|
||||
cardIndex: number;
|
||||
frameIndex: string;
|
||||
}[],
|
||||
options: {
|
||||
width: number;
|
||||
onDragEnd?: (insertIndex?: number) => void;
|
||||
onDragMove?: (insertIdx?: number, indicatorTranslateY?: number) => void;
|
||||
framePanelBody: HTMLElement;
|
||||
frameListContainer: HTMLElement;
|
||||
frameElementHeight: number;
|
||||
document: Document;
|
||||
domHost: Document | HTMLElement;
|
||||
container: FramePanelBody;
|
||||
start: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const {
|
||||
document,
|
||||
domHost,
|
||||
container,
|
||||
onDragMove,
|
||||
onDragEnd,
|
||||
frameElementHeight,
|
||||
framePanelBody,
|
||||
frameListContainer,
|
||||
start,
|
||||
} = options;
|
||||
const cardElements = frames
|
||||
.slice(frames.length - 2, frames.length)
|
||||
.map((frame, idx, arr) => {
|
||||
const el = new FrameCard();
|
||||
|
||||
el.frame = frame.frame;
|
||||
|
||||
el.cardIndex = frame.cardIndex;
|
||||
el.frameIndex = frame.frameIndex;
|
||||
el.status = 'dragging';
|
||||
el.stackOrder = arr.length - 1 - idx;
|
||||
el.pos = start;
|
||||
el.width = options.width;
|
||||
if (frames.length > 1 && el.stackOrder === 0)
|
||||
el.draggingCardNumber = frames.length;
|
||||
|
||||
return el;
|
||||
});
|
||||
const maskElement = createMaskElement(document);
|
||||
const listContainerRect = framePanelBody.getBoundingClientRect();
|
||||
const children = Array.from(frameListContainer.children) as FrameCard[];
|
||||
const computedStyle = getComputedStyle(frameListContainer);
|
||||
const frameListContainerGap =
|
||||
parseInt(computedStyle.getPropertyValue('gap')) ?? 16;
|
||||
let idx: undefined | number;
|
||||
let indicatorTranslateY: undefined | number;
|
||||
|
||||
container.renderRoot.append(maskElement);
|
||||
container.renderRoot.append(...cardElements);
|
||||
|
||||
const insideListContainer = (e: MouseEvent) => {
|
||||
return (
|
||||
e.clientX >= listContainerRect.left &&
|
||||
e.clientX <= listContainerRect.right &&
|
||||
e.clientY >= listContainerRect.top &&
|
||||
e.clientY <= listContainerRect.bottom
|
||||
);
|
||||
};
|
||||
|
||||
const disposeMove = on(container, 'mousemove', e => {
|
||||
cardElements.forEach(el => {
|
||||
el.pos = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
});
|
||||
|
||||
if (!insideListContainer(e)) {
|
||||
idx = undefined;
|
||||
onDragMove?.(idx, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
idx = 0;
|
||||
for (const card of children) {
|
||||
if (!card.frame) break;
|
||||
|
||||
const topBoundary =
|
||||
listContainerRect.top +
|
||||
card.offsetTop -
|
||||
framePanelBody.scrollTop -
|
||||
frameListContainerGap / 2;
|
||||
const midBoundary = topBoundary + card.offsetHeight / 2;
|
||||
const bottomBoundary =
|
||||
topBoundary + card.offsetHeight + frameListContainerGap;
|
||||
|
||||
if (e.clientY >= topBoundary && e.clientY <= bottomBoundary) {
|
||||
idx = e.clientY > midBoundary ? idx + 1 : idx;
|
||||
|
||||
indicatorTranslateY =
|
||||
idx * (frameElementHeight + frameListContainerGap) -
|
||||
frameListContainerGap / 2;
|
||||
|
||||
onDragMove?.(idx, indicatorTranslateY);
|
||||
return;
|
||||
}
|
||||
|
||||
++idx;
|
||||
}
|
||||
|
||||
onDragMove?.(idx);
|
||||
});
|
||||
|
||||
let ended = false;
|
||||
const dragEnd = () => {
|
||||
if (ended) return;
|
||||
|
||||
ended = true;
|
||||
cardElements.forEach(child => child.remove());
|
||||
maskElement.remove();
|
||||
|
||||
disposeMove();
|
||||
onDragEnd?.(idx);
|
||||
};
|
||||
|
||||
once(domHost as Document, 'mouseup', dragEnd);
|
||||
}
|
||||
|
||||
function createMaskElement(doc: Document) {
|
||||
const mask = doc.createElement('div');
|
||||
|
||||
mask.style.height = '100vh';
|
||||
mask.style.width = '100vw';
|
||||
mask.style.position = 'fixed';
|
||||
mask.style.left = '0';
|
||||
mask.style.top = '0';
|
||||
mask.style.zIndex = 'calc(var(--affine-z-index-popover, 0) + 3)';
|
||||
mask.style.cursor = 'grabbing';
|
||||
|
||||
return mask;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './comment/index.js';
|
||||
export * from './doc-title/doc-title.js';
|
||||
export * from './frame-panel/index.js';
|
||||
export * from './outline/index.js';
|
||||
@@ -0,0 +1,136 @@
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { SmallCloseIcon, SortingIcon } from '../../_common/icons.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.outline-notice-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
gap: 14px;
|
||||
padding: 10px;
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
background-color: var(--affine-background-overlay-panel-color);
|
||||
}
|
||||
.outline-notice-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.outline-notice-label {
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
.outline-notice-close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
.outline-notice-body {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.outline-notice-item {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
.outline-notice-item.notice {
|
||||
font-weight: 400;
|
||||
}
|
||||
.outline-notice-item.button {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.outline-notice-item.button span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 20px;
|
||||
}
|
||||
.outline-notice-item.button svg {
|
||||
scale: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice';
|
||||
|
||||
export class OutlineNotice extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _handleNoticeButtonClick() {
|
||||
this.toggleNotesSorting();
|
||||
this.setNoticeVisibility(false);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.noticeVisible) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="outline-notice-container">
|
||||
<div class="outline-notice-header">
|
||||
<span class="outline-notice-label">SOME CONTENTS HIDDEN</span>
|
||||
<span
|
||||
class="outline-notice-close-button"
|
||||
@click=${() => this.setNoticeVisibility(false)}
|
||||
>${SmallCloseIcon}</span
|
||||
>
|
||||
</div>
|
||||
<div class="outline-notice-body">
|
||||
<div class="outline-notice-item notice">
|
||||
Some contents are not visible on edgeless.
|
||||
</div>
|
||||
<div
|
||||
class="outline-notice-item button"
|
||||
@click=${this._handleNoticeButtonClick}
|
||||
>
|
||||
<span>Click here or</span>
|
||||
<span>${SortingIcon}</span>
|
||||
<span>to organize content.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor noticeVisible!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor setNoticeVisibility!: (visibility: boolean) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggleNotesSorting!: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_NOTICE]: OutlineNotice;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
import type {
|
||||
EdgelessRootBlockComponent,
|
||||
NoteBlockModel,
|
||||
} from '@blocksuite/blocks';
|
||||
import {
|
||||
BlocksUtils,
|
||||
NoteDisplayMode,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/blocks';
|
||||
import {
|
||||
Bound,
|
||||
DisposableGroup,
|
||||
SignalWatcher,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import type { Doc } from '@blocksuite/store';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { AffineEditorContainer } from '../../../editors/editor-container.js';
|
||||
import type {
|
||||
ClickBlockEvent,
|
||||
DisplayModeChangeEvent,
|
||||
FitViewEvent,
|
||||
SelectEvent,
|
||||
} from '../utils/custom-events.js';
|
||||
import { startDragging } from '../utils/drag.js';
|
||||
import {
|
||||
getHeadingBlocksFromDoc,
|
||||
getNotesFromDoc,
|
||||
isHeadingBlock,
|
||||
} from '../utils/query.js';
|
||||
import {
|
||||
observeActiveHeadingDuringScroll,
|
||||
scrollToBlockWithHighlight,
|
||||
} from '../utils/scroll.js';
|
||||
|
||||
type OutlineNoteItem = {
|
||||
note: NoteBlockModel;
|
||||
|
||||
/**
|
||||
* the index of the note inside its parent's children property
|
||||
*/
|
||||
index: number;
|
||||
|
||||
/**
|
||||
* the number displayed on the outline panel
|
||||
*/
|
||||
number: number;
|
||||
};
|
||||
|
||||
const styles = css`
|
||||
.outline-panel-body-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-list .hidden-title {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding-left: 8px;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.insert-indicator {
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background-color: var(--affine-brand-color);
|
||||
border-radius: 1px;
|
||||
position: absolute;
|
||||
contain: layout size;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.no-note-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note-placeholder {
|
||||
margin-top: 240px;
|
||||
align-self: center;
|
||||
width: 190px;
|
||||
height: 48px;
|
||||
color: var(--affine-text-secondary-color, #8e8d91);
|
||||
text-align: center;
|
||||
/* light/base */
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body';
|
||||
|
||||
export class OutlinePanelBody extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = styles;
|
||||
|
||||
private _activeHeadingId$ = signal<string | null>(null);
|
||||
|
||||
private _changedFlag = false;
|
||||
|
||||
private _clearHighlightMask = () => {};
|
||||
|
||||
private _docDisposables: DisposableGroup | null = null;
|
||||
|
||||
private _indicatorTranslateY = 0;
|
||||
|
||||
private _lockActiveHeadingId = false;
|
||||
|
||||
private _oldViewport?: {
|
||||
zoom: number;
|
||||
center: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
};
|
||||
|
||||
get viewportPadding(): [number, number, number, number] {
|
||||
return this.fitPadding
|
||||
? ([0, 0, 0, 0].map((val, idx) =>
|
||||
Number.isFinite(this.fitPadding[idx]) ? this.fitPadding[idx] : val
|
||||
) as [number, number, number, number])
|
||||
: [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
private _clearDocDisposables() {
|
||||
this._docDisposables?.dispose();
|
||||
this._docDisposables = null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Click at blank area to clear selection
|
||||
*/
|
||||
private _clickHandler(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
// check if click at outline-card, if so, do nothing
|
||||
if (
|
||||
(e.target as HTMLElement).closest('outline-note-card') ||
|
||||
this._selected.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._selected = [];
|
||||
this.edgeless?.service.selection.set({
|
||||
elements: this._selected,
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _deSelectNoteInEdgelessMode(note: NoteBlockModel) {
|
||||
if (!this._isEdgelessMode() || !this.edgeless) return;
|
||||
|
||||
const { selection } = this.edgeless.service;
|
||||
if (!selection.has(note.id)) return;
|
||||
const selectedIds = selection.selectedIds.filter(id => id !== note.id);
|
||||
selection.set({
|
||||
elements: selectedIds,
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Double click at blank area to disable notes sorting option
|
||||
*/
|
||||
private _doubleClickHandler(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
// check if click at outline-card, if so, do nothing
|
||||
if (
|
||||
(e.target as HTMLElement).closest('outline-note-card') ||
|
||||
!this.enableNotesSorting
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggleNotesSorting();
|
||||
}
|
||||
|
||||
private _drag() {
|
||||
if (
|
||||
!this._selected.length ||
|
||||
!this._pageVisibleNotes.length ||
|
||||
!this.doc.root
|
||||
)
|
||||
return;
|
||||
|
||||
this._dragging = true;
|
||||
|
||||
// cache the notes in case it is changed by other peers
|
||||
const children = this.doc.root.children.slice() as NoteBlockModel[];
|
||||
const notes = this._pageVisibleNotes;
|
||||
const notesMap = this._pageVisibleNotes.reduce((map, note, index) => {
|
||||
map.set(note.note.id, {
|
||||
...note,
|
||||
number: index + 1,
|
||||
});
|
||||
return map;
|
||||
}, new Map<string, OutlineNoteItem>());
|
||||
const selected = this._selected.slice();
|
||||
|
||||
startDragging({
|
||||
container: this,
|
||||
document: this.ownerDocument,
|
||||
host: this.domHost ?? this.ownerDocument,
|
||||
doc: this.doc,
|
||||
outlineListContainer: this.panelListElement,
|
||||
onDragEnd: insertIdx => {
|
||||
this._dragging = false;
|
||||
this.insertIndex = undefined;
|
||||
|
||||
if (insertIdx === undefined) return;
|
||||
|
||||
this._moveNotes(insertIdx, selected, notesMap, notes, children);
|
||||
},
|
||||
onDragMove: (idx, indicatorTranslateY) => {
|
||||
this.insertIndex = idx;
|
||||
this._indicatorTranslateY = indicatorTranslateY ?? 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _EmptyPanel() {
|
||||
return html`<div class="no-note-container">
|
||||
<div class="note-placeholder">
|
||||
Use headings to create a table of contents.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _fitToElement(e: FitViewEvent) {
|
||||
const edgeless = this.edgeless;
|
||||
|
||||
if (!edgeless) return;
|
||||
|
||||
const { block } = e.detail;
|
||||
const bound = Bound.deserialize(block.xywh);
|
||||
|
||||
edgeless.service.viewport.setViewportByBound(
|
||||
bound,
|
||||
this.viewportPadding,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// when display mode change to page only, we should de-select the note if it is selected in edgeless mode
|
||||
private _handleDisplayModeChange(e: DisplayModeChangeEvent) {
|
||||
const { note, newMode } = e.detail;
|
||||
const { displayMode: currentMode } = note;
|
||||
if (newMode === currentMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.doc.updateBlock(note, { displayMode: newMode });
|
||||
|
||||
const noteParent = this.doc.getParent(note);
|
||||
if (noteParent === null) {
|
||||
console.error(`Failed to get parent of note(id:${note.id})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteParentChildNotes = noteParent.children.filter(block =>
|
||||
BlocksUtils.matchFlavours(block, ['affine:note'])
|
||||
) as NoteBlockModel[];
|
||||
const noteParentLastNote =
|
||||
noteParentChildNotes[noteParentChildNotes.length - 1];
|
||||
|
||||
// When the display mode of a note change from edgeless to page visible
|
||||
// We should move the note to the end of the note list
|
||||
if (
|
||||
currentMode === NoteDisplayMode.EdgelessOnly &&
|
||||
note !== noteParentLastNote
|
||||
) {
|
||||
this.doc.moveBlocks([note], noteParent, noteParentLastNote, false);
|
||||
}
|
||||
|
||||
// When the display mode of a note changed to page only
|
||||
// We should check if the note is selected in edgeless mode
|
||||
// If so, we should de-select it
|
||||
if (newMode === NoteDisplayMode.DocOnly) {
|
||||
this._deSelectNoteInEdgelessMode(note);
|
||||
}
|
||||
}
|
||||
|
||||
private _isEdgelessMode() {
|
||||
return this.editor.mode === 'edgeless';
|
||||
}
|
||||
|
||||
private _moveNotes(
|
||||
index: number,
|
||||
selected: string[],
|
||||
notesMap: Map<string, OutlineNoteItem>,
|
||||
notes: OutlineNoteItem[],
|
||||
children: NoteBlockModel[]
|
||||
) {
|
||||
if (!this._isEdgelessMode() || !children.length || !this.doc.root) return;
|
||||
|
||||
const blocks = selected.map(
|
||||
id => (notesMap.get(id) as OutlineNoteItem).note
|
||||
);
|
||||
const draggingBlocks = new Set(blocks);
|
||||
const targetIndex =
|
||||
index === notes.length ? notes[index - 1].index + 1 : notes[index].index;
|
||||
|
||||
const leftPart = children
|
||||
.slice(0, targetIndex)
|
||||
.filter(block => !draggingBlocks.has(block));
|
||||
const rightPart = children
|
||||
.slice(targetIndex)
|
||||
.filter(block => !draggingBlocks.has(block));
|
||||
const newChildren = [...leftPart, ...blocks, ...rightPart];
|
||||
|
||||
this._changedFlag = true;
|
||||
this.doc.updateBlock(this.doc.root, {
|
||||
children: newChildren,
|
||||
});
|
||||
}
|
||||
|
||||
private _PanelList(withEdgelessOnlyNotes: boolean) {
|
||||
const selectedNotesSet = new Set(this._selected);
|
||||
const theme = this.editor.std.get(ThemeProvider).theme;
|
||||
|
||||
return html`<div class="panel-list">
|
||||
${this.insertIndex !== undefined
|
||||
? html`<div
|
||||
class="insert-indicator"
|
||||
style=${`transform: translateY(${this._indicatorTranslateY}px)`}
|
||||
></div>`
|
||||
: nothing}
|
||||
${this._renderDocTitle()}
|
||||
${this._pageVisibleNotes.length
|
||||
? repeat(
|
||||
this._pageVisibleNotes,
|
||||
note => note.note.id,
|
||||
(note, idx) => html`
|
||||
<affine-outline-note-card
|
||||
data-note-id=${note.note.id}
|
||||
.note=${note.note}
|
||||
.theme=${theme}
|
||||
.number=${idx + 1}
|
||||
.index=${note.index}
|
||||
.doc=${this.doc}
|
||||
.editorMode=${this.editor.mode}
|
||||
.activeHeadingId=${this._activeHeadingId$.value}
|
||||
.status=${selectedNotesSet.has(note.note.id)
|
||||
? this._dragging
|
||||
? 'placeholder'
|
||||
: 'selected'
|
||||
: undefined}
|
||||
.showPreviewIcon=${this.showPreviewIcon}
|
||||
.enableNotesSorting=${this.enableNotesSorting}
|
||||
@select=${this._selectNote}
|
||||
@drag=${this._drag}
|
||||
@fitview=${this._fitToElement}
|
||||
@clickblock=${(e: ClickBlockEvent) => {
|
||||
this._scrollToBlock(e.detail.blockId).catch(console.error);
|
||||
}}
|
||||
@displaymodechange=${this._handleDisplayModeChange}
|
||||
></affine-outline-note-card>
|
||||
`
|
||||
)
|
||||
: html`${nothing}`}
|
||||
${withEdgelessOnlyNotes
|
||||
? html`<div class="hidden-title">Hidden Contents</div>
|
||||
${repeat(
|
||||
this._edgelessOnlyNotes,
|
||||
note => note.note.id,
|
||||
(note, idx) =>
|
||||
html`<affine-outline-note-card
|
||||
data-note-id=${note.note.id}
|
||||
.note=${note.note}
|
||||
.theme=${theme}
|
||||
.number=${idx + 1}
|
||||
.index=${note.index}
|
||||
.doc=${this.doc}
|
||||
.activeHeadingId=${this._activeHeadingId$.value}
|
||||
.invisible=${true}
|
||||
.showPreviewIcon=${this.showPreviewIcon}
|
||||
.enableNotesSorting=${this.enableNotesSorting}
|
||||
@fitview=${this._fitToElement}
|
||||
@displaymodechange=${this._handleDisplayModeChange}
|
||||
></affine-outline-note-card>`
|
||||
)} `
|
||||
: nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderDocTitle() {
|
||||
if (!this.doc.root) return nothing;
|
||||
|
||||
const hasNotEmptyHeadings =
|
||||
getHeadingBlocksFromDoc(
|
||||
this.doc,
|
||||
[NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless],
|
||||
true
|
||||
).length > 0;
|
||||
|
||||
if (!hasNotEmptyHeadings) return nothing;
|
||||
|
||||
return html`<affine-outline-block-preview
|
||||
class=${classMap({
|
||||
active: this.doc.root.id === this._activeHeadingId$.value,
|
||||
})}
|
||||
.block=${this.doc.root}
|
||||
.className=${this.doc.root?.id === this._activeHeadingId$.value
|
||||
? 'active'
|
||||
: ''}
|
||||
.cardNumber=${1}
|
||||
.enableNotesSorting=${false}
|
||||
.showPreviewIcon=${this.showPreviewIcon}
|
||||
@click=${() => {
|
||||
if (!this.doc.root) return;
|
||||
this._scrollToBlock(this.doc.root.id).catch(console.error);
|
||||
}}
|
||||
></affine-outline-block-preview>`;
|
||||
}
|
||||
|
||||
private async _scrollToBlock(blockId: string) {
|
||||
this._lockActiveHeadingId = true;
|
||||
this._activeHeadingId$.value = blockId;
|
||||
this._clearHighlightMask = await scrollToBlockWithHighlight(
|
||||
this.editor,
|
||||
blockId
|
||||
);
|
||||
this._lockActiveHeadingId = false;
|
||||
}
|
||||
|
||||
private _selectNote(e: SelectEvent) {
|
||||
if (!this._isEdgelessMode()) return;
|
||||
|
||||
const { selected, id, multiselect } = e.detail;
|
||||
|
||||
if (!selected) {
|
||||
this._selected = this._selected.filter(noteId => noteId !== id);
|
||||
} else if (multiselect) {
|
||||
this._selected = [...this._selected, id];
|
||||
} else {
|
||||
this._selected = [id];
|
||||
}
|
||||
|
||||
// When edgeless mode, should select notes which display in both mode
|
||||
const selectedIds = this._pageVisibleNotes.reduce((ids, item) => {
|
||||
const note = item.note;
|
||||
if (
|
||||
this._selected.includes(note.id) &&
|
||||
(!note.displayMode ||
|
||||
note.displayMode === NoteDisplayMode.DocAndEdgeless)
|
||||
) {
|
||||
ids.push(note.id);
|
||||
}
|
||||
return ids;
|
||||
}, [] as string[]);
|
||||
this.edgeless?.service.selection.set({
|
||||
elements: selectedIds,
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _setDocDisposables() {
|
||||
this._clearDocDisposables();
|
||||
this._docDisposables = new DisposableGroup();
|
||||
this._docDisposables.add(
|
||||
effect(() => {
|
||||
this._updateNotes();
|
||||
this._updateNoticeVisibility();
|
||||
})
|
||||
);
|
||||
this._docDisposables.add(
|
||||
this.doc.slots.blockUpdated.on(payload => {
|
||||
if (
|
||||
payload.type === 'update' &&
|
||||
payload.flavour === 'affine:note' &&
|
||||
payload.props.key === 'displayMode'
|
||||
) {
|
||||
this._updateNotes();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* There are two cases that we should render note list:
|
||||
* 1. There are headings in the notes
|
||||
* 2. No headings, but there are blocks in the notes and note sorting option is enabled
|
||||
*/
|
||||
private _shouldRenderNoteList(noteItems: OutlineNoteItem[]) {
|
||||
if (!noteItems.length) return false;
|
||||
|
||||
let hasHeadings = false;
|
||||
let hasChildrenBlocks = false;
|
||||
|
||||
for (const noteItem of noteItems) {
|
||||
for (const block of noteItem.note.children) {
|
||||
hasChildrenBlocks = true;
|
||||
|
||||
if (isHeadingBlock(block)) {
|
||||
hasHeadings = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHeadings) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return hasHeadings || (this.enableNotesSorting && hasChildrenBlocks);
|
||||
}
|
||||
|
||||
private _updateNotes() {
|
||||
const rootModel = this.doc.root;
|
||||
|
||||
if (this._dragging) return;
|
||||
|
||||
if (!rootModel) {
|
||||
this._pageVisibleNotes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const oldSelectedSet = this._selected.reduce((pre, id) => {
|
||||
pre.add(id);
|
||||
return pre;
|
||||
}, new Set<string>());
|
||||
const newSelected: string[] = [];
|
||||
|
||||
rootModel.children.forEach(block => {
|
||||
if (!BlocksUtils.matchFlavours(block, ['affine:note'])) return;
|
||||
|
||||
const blockModel = block as NoteBlockModel;
|
||||
|
||||
if (
|
||||
blockModel.displayMode !== NoteDisplayMode.EdgelessOnly &&
|
||||
oldSelectedSet.has(block.id)
|
||||
) {
|
||||
newSelected.push(block.id);
|
||||
}
|
||||
});
|
||||
|
||||
this._pageVisibleNotes = getNotesFromDoc(this.doc, [
|
||||
NoteDisplayMode.DocAndEdgeless,
|
||||
NoteDisplayMode.DocOnly,
|
||||
]);
|
||||
this._edgelessOnlyNotes = getNotesFromDoc(this.doc, [
|
||||
NoteDisplayMode.EdgelessOnly,
|
||||
]);
|
||||
this._selected = newSelected;
|
||||
}
|
||||
|
||||
private _updateNoticeVisibility() {
|
||||
if (this.enableNotesSorting) {
|
||||
if (this.noticeVisible) {
|
||||
this.setNoticeVisibility(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldShowNotice = this._pageVisibleNotes.some(
|
||||
note => note.note.displayMode === NoteDisplayMode.DocOnly
|
||||
);
|
||||
|
||||
if (shouldShowNotice && !this.noticeVisible) {
|
||||
this.setNoticeVisibility(true);
|
||||
}
|
||||
}
|
||||
|
||||
private _zoomToFit() {
|
||||
const edgeless = this.edgeless;
|
||||
|
||||
if (!edgeless) return;
|
||||
|
||||
const bound = edgeless.gfx.elementsBound;
|
||||
|
||||
this._oldViewport = {
|
||||
zoom: edgeless.service.viewport.zoom,
|
||||
center: {
|
||||
x: edgeless.service.viewport.center.x,
|
||||
y: edgeless.service.viewport.center.y,
|
||||
},
|
||||
};
|
||||
edgeless.service.viewport.setViewportByBound(
|
||||
new Bound(bound.x, bound.y, bound.w, bound.h),
|
||||
this.viewportPadding,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.disposables.add(
|
||||
observeActiveHeadingDuringScroll(
|
||||
() => this.editor,
|
||||
newHeadingId => {
|
||||
if (this._lockActiveHeadingId) return;
|
||||
this._activeHeadingId$.value = newHeadingId;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
if (!this._changedFlag && this._oldViewport) {
|
||||
const edgeless = this.edgeless;
|
||||
|
||||
if (!edgeless) return;
|
||||
|
||||
edgeless.service.viewport.setViewport(
|
||||
this._oldViewport.zoom,
|
||||
[this._oldViewport.center.x, this._oldViewport.center.y],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
this._clearDocDisposables();
|
||||
this._clearHighlightMask();
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.disposables.addFromEvent(this, 'click', this._clickHandler);
|
||||
this.disposables.addFromEvent(this, 'dblclick', this._doubleClickHandler);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const shouldRenderPageVisibleNotes = this._shouldRenderNoteList(
|
||||
this._pageVisibleNotes
|
||||
);
|
||||
const shouldRenderEdgelessOnlyNotes =
|
||||
this.renderEdgelessOnlyNotes &&
|
||||
this._shouldRenderNoteList(this._edgelessOnlyNotes);
|
||||
|
||||
const shouldRenderEmptyPanel =
|
||||
!shouldRenderPageVisibleNotes && !shouldRenderEdgelessOnlyNotes;
|
||||
|
||||
return html`
|
||||
<div class="outline-panel-body-container">
|
||||
${shouldRenderEmptyPanel
|
||||
? this._EmptyPanel()
|
||||
: this._PanelList(shouldRenderEdgelessOnlyNotes)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override willUpdate(_changedProperties: PropertyValues) {
|
||||
if (_changedProperties.has('doc') || _changedProperties.has('edgeless')) {
|
||||
this._setDocDisposables();
|
||||
}
|
||||
|
||||
if (
|
||||
_changedProperties.has('mode') &&
|
||||
this.edgeless &&
|
||||
this._isEdgelessMode()
|
||||
) {
|
||||
this._clearHighlightMask();
|
||||
if (_changedProperties.get('mode') === undefined) return;
|
||||
|
||||
requestAnimationFrame(() => this._zoomToFit());
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _dragging = false;
|
||||
|
||||
@state()
|
||||
private accessor _edgelessOnlyNotes: OutlineNoteItem[] = [];
|
||||
|
||||
@state()
|
||||
private accessor _pageVisibleNotes: OutlineNoteItem[] = [];
|
||||
|
||||
/**
|
||||
* store the id of selected notes
|
||||
*/
|
||||
@state()
|
||||
private accessor _selected: string[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Doc;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor domHost!: Document | HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor edgeless!: EdgelessRootBlockComponent | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: AffineEditorContainer;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor enableNotesSorting!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fitPadding!: number[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor insertIndex: number | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor noticeVisible!: boolean;
|
||||
|
||||
@query('.outline-panel-body-container')
|
||||
accessor OutlinePanelContainer!: HTMLElement;
|
||||
|
||||
@query('.panel-list')
|
||||
accessor panelListElement!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor renderEdgelessOnlyNotes: boolean = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor setNoticeVisibility!: (visibility: boolean) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showPreviewIcon!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggleNotesSorting!: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_PANEL_BODY]: OutlinePanelBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import {
|
||||
type ColorScheme,
|
||||
createButtonPopper,
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
on,
|
||||
once,
|
||||
} from '@blocksuite/blocks';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { BlockModel, Doc } from '@blocksuite/store';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
import { HiddenIcon, SmallArrowDownIcon } from '../../_common/icons.js';
|
||||
import type { SelectEvent } from '../utils/custom-events.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
position: relative;
|
||||
|
||||
width: 100%;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-preview.edgeless:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.card-header-container {
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-header-container.enable-sorting {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card-header-container .card-number {
|
||||
text-align: center;
|
||||
font-size: var(--affine-font-sm);
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
color: var(--affine-brand-color, #1e96eb);
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.card-header-container .card-header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-header-container .card-divider {
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
border-top: 1px dashed var(--affine-border-color);
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.display-mode-button-group {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: -6px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.card-preview:hover .display-mode-button-group {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.display-mode-button-label {
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.display-mode-button {
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
background-color: var(--affine-hover-color);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.current-mode-label {
|
||||
display: flex;
|
||||
padding: 2px 0px 2px 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
note-display-mode-panel {
|
||||
position: absolute;
|
||||
display: none;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
font-size: var(--affine-font-sm);
|
||||
color: var(--affine-text-primary-color);
|
||||
line-height: 22px;
|
||||
font-weight: 400;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
}
|
||||
|
||||
note-display-mode-panel[data-show] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
user-select: none;
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.card-preview.edgeless .card-content:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-preview.edgeless .card-header-container:hover {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.card-container.placeholder {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.card-container.selected .card-preview.edgeless {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.card-container.placeholder .card-preview.edgeless {
|
||||
background: var(--affine-hover-color);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.card-container[data-sortable='true'] {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.card-container[data-invisible='true'] .card-header-container .card-number,
|
||||
.card-container[data-invisible='true']
|
||||
.card-header-container
|
||||
.card-header-icon,
|
||||
.card-container[data-invisible='true'] .card-preview .card-content {
|
||||
color: var(--affine-text-disable-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-preview.page outline-block-preview:hover {
|
||||
color: var(--affine-brand-color);
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card';
|
||||
|
||||
export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
static override styles = styles;
|
||||
|
||||
private _displayModePopper: ReturnType<typeof createButtonPopper> | null =
|
||||
null;
|
||||
|
||||
private _dispatchClickBlockEvent(block: BlockModel) {
|
||||
const event = new CustomEvent('clickblock', {
|
||||
detail: {
|
||||
blockId: block.id,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _dispatchDisplayModeChangeEvent(
|
||||
note: NoteBlockModel,
|
||||
newMode: NoteDisplayMode
|
||||
) {
|
||||
const event = new CustomEvent('displaymodechange', {
|
||||
detail: {
|
||||
note,
|
||||
newMode,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _dispatchDragEvent(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (
|
||||
e.button !== 0 ||
|
||||
this.editorMode === 'page' ||
|
||||
!this.enableNotesSorting
|
||||
)
|
||||
return;
|
||||
|
||||
const { clientX: startX, clientY: startY } = e;
|
||||
const disposeDragStart = on(this.ownerDocument, 'mousemove', e => {
|
||||
if (
|
||||
Math.abs(startX - e.clientX) < 5 &&
|
||||
Math.abs(startY - e.clientY) < 5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.status !== 'selected') {
|
||||
this._dispatchSelectEvent(e);
|
||||
}
|
||||
|
||||
const event = new CustomEvent('drag');
|
||||
|
||||
this.dispatchEvent(event);
|
||||
disposeDragStart();
|
||||
});
|
||||
|
||||
once(this.ownerDocument, 'mouseup', () => {
|
||||
disposeDragStart();
|
||||
});
|
||||
}
|
||||
|
||||
private _dispatchFitViewEvent(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
|
||||
const event = new CustomEvent('fitview', {
|
||||
detail: {
|
||||
block: this.note,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _dispatchSelectEvent(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const event = new CustomEvent('select', {
|
||||
detail: {
|
||||
id: this.note.id,
|
||||
selected: this.status !== 'selected',
|
||||
number: this.number,
|
||||
multiselect: e.shiftKey,
|
||||
},
|
||||
}) as SelectEvent;
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _getCurrentModeLabel(mode: NoteDisplayMode) {
|
||||
switch (mode) {
|
||||
case NoteDisplayMode.DocAndEdgeless:
|
||||
return 'Both';
|
||||
case NoteDisplayMode.EdgelessOnly:
|
||||
return 'Edgeless';
|
||||
case NoteDisplayMode.DocOnly:
|
||||
return 'Page';
|
||||
default:
|
||||
return 'Both';
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._displayModePopper = createButtonPopper(
|
||||
this._displayModeButtonGroup,
|
||||
this._displayModePanel,
|
||||
({ display }) => {
|
||||
this._showPopper = display === 'show';
|
||||
},
|
||||
{
|
||||
mainAxis: 0,
|
||||
crossAxis: -60,
|
||||
}
|
||||
);
|
||||
|
||||
this.disposables.add(this._displayModePopper);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.note.isEmpty.peek()) return nothing;
|
||||
|
||||
const { children, displayMode } = this.note;
|
||||
const currentMode = this._getCurrentModeLabel(displayMode);
|
||||
const cardHeaderClasses = classMap({
|
||||
'card-header-container': true,
|
||||
'enable-sorting': this.enableNotesSorting,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div
|
||||
data-invisible="${this.invisible ? 'true' : 'false'}"
|
||||
data-sortable="${this.enableNotesSorting ? 'true' : 'false'}"
|
||||
class="card-container ${this.status ?? ''} ${this.theme}"
|
||||
>
|
||||
<div
|
||||
class="card-preview ${this.editorMode}"
|
||||
@mousedown=${this._dispatchDragEvent}
|
||||
@click=${this._dispatchSelectEvent}
|
||||
@dblclick=${this._dispatchFitViewEvent}
|
||||
>
|
||||
${html`<div class=${cardHeaderClasses}>
|
||||
${
|
||||
this.invisible
|
||||
? html`<span class="card-header-icon">${HiddenIcon}</span>`
|
||||
: html`<span class="card-number">${this.number}</span>`
|
||||
}
|
||||
<span class="card-divider"></span>
|
||||
<div class="display-mode-button-group">
|
||||
<span class="display-mode-button-label">Show in</span>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${this._showPopper ? '' : 'Display Mode'}
|
||||
.tipPosition=${'left-start'}
|
||||
.iconContainerPadding=${0}
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
this._displayModePopper?.toggle();
|
||||
}}
|
||||
@dblclick=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div class="display-mode-button">
|
||||
<span class="current-mode-label">${currentMode}</span>
|
||||
${SmallArrowDownIcon}
|
||||
</div>
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
<note-display-mode-panel
|
||||
.displayMode=${displayMode}
|
||||
.panelWidth=${220}
|
||||
.onSelect=${(newMode: NoteDisplayMode) => {
|
||||
this._dispatchDisplayModeChangeEvent(this.note, newMode);
|
||||
this._displayModePopper?.hide();
|
||||
}}
|
||||
>
|
||||
</note-display-mode-panel>
|
||||
</div>`}
|
||||
<div class="card-content">
|
||||
${children.map(block => {
|
||||
return html`<affine-outline-block-preview
|
||||
.block=${block}
|
||||
.className=${this.activeHeadingId === block.id ? 'active' : ''}
|
||||
.showPreviewIcon=${this.showPreviewIcon}
|
||||
.disabledIcon=${this.invisible}
|
||||
.cardNumber=${this.number}
|
||||
.enableNotesSorting=${this.enableNotesSorting}
|
||||
@click=${() => {
|
||||
if (this.editorMode === 'edgeless' || this.invisible) return;
|
||||
this._dispatchClickBlockEvent(block);
|
||||
}}
|
||||
></affine-outline-block-preview>`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.display-mode-button-group')
|
||||
private accessor _displayModeButtonGroup!: HTMLDivElement;
|
||||
|
||||
@query('note-display-mode-panel')
|
||||
private accessor _displayModePanel!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
private accessor _showPopper = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor activeHeadingId: string | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Doc;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editorMode: 'page' | 'edgeless' = 'page';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor enableNotesSorting!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor index!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor invisible = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor note!: NoteBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor number!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showPreviewIcon!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor status: 'selected' | 'placeholder' | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor theme!: ColorScheme;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_NOTE_CARD]: OutlineNoteCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type {
|
||||
AttachmentBlockModel,
|
||||
BookmarkBlockModel,
|
||||
CodeBlockModel,
|
||||
DatabaseBlockModel,
|
||||
ImageBlockModel,
|
||||
ListBlockModel,
|
||||
ParagraphBlockModel,
|
||||
RootBlockModel,
|
||||
} from '@blocksuite/blocks';
|
||||
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { SmallLinkedDocIcon } from '../../_common/icons.js';
|
||||
import { placeholderMap, previewIconMap } from '../config.js';
|
||||
import { isHeadingBlock, isRootBlock } from '../utils/query.js';
|
||||
|
||||
type ValuesOf<T, K extends keyof T = keyof T> = T[K];
|
||||
|
||||
function assertType<T>(value: unknown): asserts value is T {
|
||||
noop(value);
|
||||
}
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: var(--affine-font-family);
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
cursor: pointer;
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
:host(.active) {
|
||||
color: var(--affine-text-emphasis-color);
|
||||
}
|
||||
|
||||
.outline-block-preview {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
background: var(--affine-background-secondary-color);
|
||||
border-radius: 4px;
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.icon.disabled {
|
||||
color: var(--affine-disabled-icon-color);
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
|
||||
font-size: var(--affine-font-sm);
|
||||
line-height: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.text.general,
|
||||
.subtype.text,
|
||||
.subtype.quote {
|
||||
font-weight: 400;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.subtype.title,
|
||||
.subtype.h1,
|
||||
.subtype.h2,
|
||||
.subtype.h3,
|
||||
.subtype.h4,
|
||||
.subtype.h5,
|
||||
.subtype.h6 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtype.title {
|
||||
padding-left: 0;
|
||||
}
|
||||
.subtype.h1 {
|
||||
padding-left: 0;
|
||||
}
|
||||
.subtype.h2 {
|
||||
padding-left: 4px;
|
||||
}
|
||||
.subtype.h3 {
|
||||
padding-left: 12px;
|
||||
}
|
||||
.subtype.h4 {
|
||||
padding-left: 16px;
|
||||
}
|
||||
.subtype.h5 {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.subtype.h6 {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.outline-block-preview:not(:has(span)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.linked-doc-preview svg {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
vertical-align: middle;
|
||||
font-size: inherit;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
|
||||
.linked-doc-text {
|
||||
font-size: inherit;
|
||||
border-bottom: 0.5px solid var(--affine-divider-color);
|
||||
white-space: break-spaces;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.linked-doc-preview.unavailable svg {
|
||||
color: var(--affine-text-disable-color);
|
||||
}
|
||||
|
||||
.linked-doc-preview.unavailable .linked-doc-text {
|
||||
color: var(--affine-text-disable-color);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_OUTLINE_BLOCK_PREVIEW = 'affine-outline-block-preview';
|
||||
|
||||
export class OutlineBlockPreview extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = styles;
|
||||
|
||||
private _TextBlockPreview(block: ParagraphBlockModel | ListBlockModel) {
|
||||
const deltas: DeltaInsert<AffineTextAttributes>[] =
|
||||
block.text.yText.toDelta();
|
||||
if (!block.text.length) return nothing;
|
||||
const iconClass = this.disabledIcon ? 'icon disabled' : 'icon';
|
||||
|
||||
const previewText = deltas.map(delta => {
|
||||
if (delta.attributes?.reference) {
|
||||
// If linked doc, render linked doc icon and the doc title.
|
||||
const refAttribute = delta.attributes.reference;
|
||||
const refMeta = block.doc.collection.meta.docMetas.find(
|
||||
doc => doc.id === refAttribute.pageId
|
||||
);
|
||||
const unavailable = !refMeta;
|
||||
const title = unavailable ? 'Deleted doc' : refMeta.title;
|
||||
return html`<span
|
||||
class="linked-doc-preview ${unavailable ? 'unavailable' : ''}"
|
||||
>${SmallLinkedDocIcon}
|
||||
<span class="linked-doc-text"
|
||||
>${title.length ? title : 'Untitled'}</span
|
||||
></span
|
||||
>`;
|
||||
} else {
|
||||
// If not linked doc, render the text.
|
||||
return delta.insert.toString().trim().length > 0
|
||||
? html`<span>${delta.insert.toString()}</span>`
|
||||
: nothing;
|
||||
}
|
||||
});
|
||||
|
||||
return html`<span class="text subtype ${block.type}">${previewText}</span>
|
||||
${this.showPreviewIcon
|
||||
? html`<span class=${iconClass}>${previewIconMap[block.type]}</span>`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class="outline-block-preview">
|
||||
${this.renderBlockByFlavour()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderBlockByFlavour() {
|
||||
const { block } = this;
|
||||
const iconClass = this.disabledIcon ? 'icon disabled' : 'icon';
|
||||
|
||||
if (
|
||||
!this.enableNotesSorting &&
|
||||
!isHeadingBlock(block) &&
|
||||
!isRootBlock(block)
|
||||
)
|
||||
return nothing;
|
||||
|
||||
switch (block.flavour as keyof BlockSuite.BlockModels) {
|
||||
case 'affine:page':
|
||||
assertType<RootBlockModel>(block);
|
||||
return block.title.length > 0
|
||||
? html`<span class="text subtype title">
|
||||
${block.title$.value}
|
||||
</span>`
|
||||
: nothing;
|
||||
case 'affine:paragraph':
|
||||
assertType<ParagraphBlockModel>(block);
|
||||
return this._TextBlockPreview(block);
|
||||
case 'affine:list':
|
||||
assertType<ListBlockModel>(block);
|
||||
return this._TextBlockPreview(block);
|
||||
case 'affine:bookmark':
|
||||
assertType<BookmarkBlockModel>(block);
|
||||
return html`
|
||||
<span class="text general"
|
||||
>${block.title || block.url || placeholderMap['bookmark']}</span
|
||||
>
|
||||
${this.showPreviewIcon
|
||||
? html`<span class=${iconClass}
|
||||
>${previewIconMap['bookmark']}</span
|
||||
>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'affine:code':
|
||||
assertType<CodeBlockModel>(block);
|
||||
return html`
|
||||
<span class="text general"
|
||||
>${block.language ?? placeholderMap['code']}</span
|
||||
>
|
||||
${this.showPreviewIcon
|
||||
? html`<span class=${iconClass}>${previewIconMap['code']}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'affine:database':
|
||||
assertType<DatabaseBlockModel>(block);
|
||||
return html`
|
||||
<span class="text general"
|
||||
>${block.title.toString().length
|
||||
? block.title.toString()
|
||||
: placeholderMap['database']}</span
|
||||
>
|
||||
${this.showPreviewIcon
|
||||
? html`<span class=${iconClass}>${previewIconMap['table']}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'affine:image':
|
||||
assertType<ImageBlockModel>(block);
|
||||
return html`
|
||||
<span class="text general"
|
||||
>${block.caption?.length
|
||||
? block.caption
|
||||
: placeholderMap['image']}</span
|
||||
>
|
||||
${this.showPreviewIcon
|
||||
? html`<span class=${iconClass}>${previewIconMap['image']}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'affine:attachment':
|
||||
assertType<AttachmentBlockModel>(block);
|
||||
return html`
|
||||
<span class="text general"
|
||||
>${block.name?.length
|
||||
? block.name
|
||||
: placeholderMap['attachment']}</span
|
||||
>
|
||||
${this.showPreviewIcon
|
||||
? html`<span class=${iconClass}
|
||||
>${previewIconMap['attachment']}</span
|
||||
>`
|
||||
: nothing}
|
||||
`;
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor block!: ValuesOf<BlockSuite.BlockModels>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor cardNumber!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor disabledIcon = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor enableNotesSorting!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showPreviewIcon!: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_BLOCK_PREVIEW]: OutlineBlockPreview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ParagraphBlockModel } from '@blocksuite/blocks';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import {
|
||||
BlockPreviewIcon,
|
||||
SmallAttachmentIcon,
|
||||
SmallBookmarkIcon,
|
||||
SmallBulletListIcon,
|
||||
SmallCodeBlockIcon,
|
||||
SmallDatabaseKanbanIcon,
|
||||
SmallDatabaseTableIcon,
|
||||
SmallHeading1Icon,
|
||||
SmallHeading2Icon,
|
||||
SmallHeading3Icon,
|
||||
SmallHeading4Icon,
|
||||
SmallHeading5Icon,
|
||||
SmallHeading6Icon,
|
||||
SmallImageIcon,
|
||||
SmallNumberListIcon,
|
||||
SmallQuoteBlockIcon,
|
||||
SmallTextIcon,
|
||||
SmallTodoIcon,
|
||||
} from '../_common/icons.js';
|
||||
|
||||
const paragraphIconMap: Record<
|
||||
ParagraphBlockModel['type'],
|
||||
TemplateResult<1>
|
||||
> = {
|
||||
quote: SmallQuoteBlockIcon,
|
||||
text: SmallTextIcon,
|
||||
h1: SmallHeading1Icon,
|
||||
h2: SmallHeading2Icon,
|
||||
h3: SmallHeading3Icon,
|
||||
h4: SmallHeading4Icon,
|
||||
h5: SmallHeading5Icon,
|
||||
h6: SmallHeading6Icon,
|
||||
};
|
||||
|
||||
export const previewIconMap = {
|
||||
...paragraphIconMap,
|
||||
code: SmallCodeBlockIcon,
|
||||
numbered: SmallNumberListIcon,
|
||||
bulleted: SmallBulletListIcon,
|
||||
todo: SmallTodoIcon,
|
||||
toggle: BlockPreviewIcon,
|
||||
bookmark: SmallBookmarkIcon,
|
||||
image: SmallImageIcon,
|
||||
table: SmallDatabaseTableIcon,
|
||||
kanban: SmallDatabaseKanbanIcon,
|
||||
attachment: SmallAttachmentIcon,
|
||||
};
|
||||
|
||||
const paragraphPlaceholderMap: Record<ParagraphBlockModel['type'], string> = {
|
||||
quote: 'Quote',
|
||||
text: 'Text Block',
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
h4: 'Heading 4',
|
||||
h5: 'Heading 5',
|
||||
h6: 'Heading 6',
|
||||
};
|
||||
|
||||
export const placeholderMap = {
|
||||
code: 'Code Block',
|
||||
bulleted: 'Bulleted List',
|
||||
numbered: 'Numbered List',
|
||||
toggle: 'Toggle List',
|
||||
todo: 'Todo',
|
||||
bookmark: 'Bookmark',
|
||||
image: 'Image',
|
||||
database: 'Database',
|
||||
attachment: 'Attachment',
|
||||
...paragraphPlaceholderMap,
|
||||
};
|
||||
|
||||
export const headingKeys = new Set(
|
||||
Object.keys(paragraphPlaceholderMap).filter(key => key.startsWith('h'))
|
||||
);
|
||||
|
||||
export const outlineSettingsKey = 'outlinePanelSettings';
|
||||
|
||||
export type OutlineSettingsDataType = {
|
||||
showIcons: boolean;
|
||||
enableSorting: boolean;
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createButtonPopper } from '@blocksuite/blocks';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import { SettingsIcon, SortingIcon } from '../../_common/icons.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.outline-panel-header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.note-setting-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.outline-panel-header-label {
|
||||
width: 119px;
|
||||
height: 22px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: var(--affine-text-secondary-color, #8e8d91);
|
||||
}
|
||||
|
||||
.note-sorting-button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.note-setting-button svg,
|
||||
.note-sorting-button svg {
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.note-setting-button:hover svg,
|
||||
.note-setting-button.active svg,
|
||||
.note-sorting-button:hover svg {
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.note-sorting-button.active svg {
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.note-preview-setting-container {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-shadow-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note-preview-setting-container[data-show] {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_OUTLINE_PANEL_HEADER = 'affine-outline-panel-header';
|
||||
|
||||
export class OutlinePanelHeader extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _notePreviewSettingMenuPopper: ReturnType<
|
||||
typeof createButtonPopper
|
||||
> | null = null;
|
||||
|
||||
override firstUpdated() {
|
||||
const _disposables = this._disposables;
|
||||
|
||||
this._notePreviewSettingMenuPopper = createButtonPopper(
|
||||
this._noteSettingButton,
|
||||
this._notePreviewSettingMenu,
|
||||
({ display }) => {
|
||||
this._settingPopperShow = display === 'show';
|
||||
},
|
||||
{
|
||||
mainAxis: 14,
|
||||
crossAxis: -30,
|
||||
}
|
||||
);
|
||||
_disposables.add(this._notePreviewSettingMenuPopper);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class="outline-panel-header-container">
|
||||
<div class="note-setting-container">
|
||||
<span class="outline-panel-header-label">Table of Contents</span>
|
||||
<edgeless-tool-icon-button
|
||||
class="note-setting-button ${this._settingPopperShow
|
||||
? 'active'
|
||||
: ''}"
|
||||
.tooltip=${this._settingPopperShow ? '' : 'Preview Settings'}
|
||||
.tipPosition=${'bottom'}
|
||||
.active=${this._settingPopperShow}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => this._notePreviewSettingMenuPopper?.toggle()}
|
||||
>
|
||||
${SettingsIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
<edgeless-tool-icon-button
|
||||
class="note-sorting-button ${this.enableNotesSorting ? 'active' : ''}"
|
||||
.tooltip=${'Visibility and sort'}
|
||||
.tipPosition=${'left'}
|
||||
.iconContainerPadding=${0}
|
||||
.active=${this.enableNotesSorting}
|
||||
.activeMode=${'color'}
|
||||
@click=${() => this.toggleNotesSorting()}
|
||||
>
|
||||
${SortingIcon}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
<div class="note-preview-setting-container">
|
||||
<affine-outline-note-preview-setting-menu
|
||||
.showPreviewIcon=${this.showPreviewIcon}
|
||||
.toggleShowPreviewIcon=${this.toggleShowPreviewIcon}
|
||||
></affine-outline-note-preview-setting-menu>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@query('.note-preview-setting-container')
|
||||
private accessor _notePreviewSettingMenu!: HTMLDivElement;
|
||||
|
||||
@query('.note-setting-button')
|
||||
private accessor _noteSettingButton!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
private accessor _settingPopperShow = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor enableNotesSorting!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showPreviewIcon!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggleNotesSorting!: () => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggleShowPreviewIcon!: (on: boolean) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_PANEL_HEADER]: OutlinePanelHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.note-preview-setting-menu-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note-preview-setting-menu-item {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 4px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note-preview-setting-menu-item .setting-label {
|
||||
font-family: sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.note-preview-setting-menu-item.action {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.note-preview-setting-menu-item .action-label {
|
||||
width: 138px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.note-preview-setting-menu-item .toggle-button {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU =
|
||||
'affine-outline-note-preview-setting-menu';
|
||||
|
||||
export class OutlineNotePreviewSettingMenu extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
override render() {
|
||||
return html`<div
|
||||
class="note-preview-setting-menu-container"
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div class="note-preview-setting-menu-item">
|
||||
<div class="setting-label">Settings</div>
|
||||
</div>
|
||||
<div class="note-preview-setting-menu-item action">
|
||||
<div class="action-label">Show type icon</div>
|
||||
<div class="toggle-button">
|
||||
<toggle-switch
|
||||
.on=${this.showPreviewIcon}
|
||||
.onChange=${this.toggleShowPreviewIcon}
|
||||
></toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showPreviewIcon!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggleShowPreviewIcon!: (on: boolean) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU]: OutlineNotePreviewSettingMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './outline-panel.js';
|
||||
export * from './outline-viewer.js';
|
||||
@@ -0,0 +1,172 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, unsafeCSS } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
import type { AffineEditorContainer } from '../../editors/editor-container.js';
|
||||
import { type OutlineSettingsDataType, outlineSettingsKey } from './config.js';
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.outline-panel-container {
|
||||
background-color: var(--affine-background-primary-color);
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
height: 100%;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
padding-top: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.outline-panel-body {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.outline-panel-body::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.outline-panel-body::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
}
|
||||
.outline-panel-body:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--affine-black-30);
|
||||
}
|
||||
.outline-panel-body::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
.outline-panel-body::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel';
|
||||
|
||||
export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
static override styles = styles;
|
||||
|
||||
private _setNoticeVisibility = (visibility: boolean) => {
|
||||
this._noticeVisible = visibility;
|
||||
};
|
||||
|
||||
private _settings: OutlineSettingsDataType = {
|
||||
showIcons: false,
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
private _toggleNotesSorting = () => {
|
||||
this._enableNotesSorting = !this._enableNotesSorting;
|
||||
this._updateAndSaveSettings({ enableSorting: this._enableNotesSorting });
|
||||
};
|
||||
|
||||
private _toggleShowPreviewIcon = (on: boolean) => {
|
||||
this._showPreviewIcon = on;
|
||||
this._updateAndSaveSettings({ showIcons: on });
|
||||
};
|
||||
|
||||
get doc() {
|
||||
return this.editor.doc;
|
||||
}
|
||||
|
||||
get edgeless() {
|
||||
return this.editor.querySelector('affine-edgeless-root');
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.editor.host;
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.editor.mode;
|
||||
}
|
||||
|
||||
private _loadSettingsFromLocalStorage() {
|
||||
const settings = localStorage.getItem(outlineSettingsKey);
|
||||
if (settings) {
|
||||
this._settings = JSON.parse(settings);
|
||||
this._showPreviewIcon = this._settings.showIcons;
|
||||
this._enableNotesSorting = this._settings.enableSorting;
|
||||
}
|
||||
}
|
||||
|
||||
private _saveSettingsToLocalStorage() {
|
||||
localStorage.setItem(outlineSettingsKey, JSON.stringify(this._settings));
|
||||
}
|
||||
|
||||
private _updateAndSaveSettings(
|
||||
newSettings: Partial<OutlineSettingsDataType>
|
||||
) {
|
||||
this._settings = { ...this._settings, ...newSettings };
|
||||
this._saveSettingsToLocalStorage();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._loadSettingsFromLocalStorage();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.host) return;
|
||||
|
||||
return html`
|
||||
<div class="outline-panel-container">
|
||||
<affine-outline-panel-header
|
||||
.showPreviewIcon=${this._showPreviewIcon}
|
||||
.enableNotesSorting=${this._enableNotesSorting}
|
||||
.toggleShowPreviewIcon=${this._toggleShowPreviewIcon}
|
||||
.toggleNotesSorting=${this._toggleNotesSorting}
|
||||
></affine-outline-panel-header>
|
||||
<affine-outline-panel-body
|
||||
class="outline-panel-body"
|
||||
.doc=${this.doc}
|
||||
.fitPadding=${this.fitPadding}
|
||||
.edgeless=${this.edgeless}
|
||||
.editor=${this.editor}
|
||||
.mode=${this.mode}
|
||||
.showPreviewIcon=${this._showPreviewIcon}
|
||||
.enableNotesSorting=${this._enableNotesSorting}
|
||||
.toggleNotesSorting=${this._toggleNotesSorting}
|
||||
.noticeVisible=${this._noticeVisible}
|
||||
.setNoticeVisibility=${this._setNoticeVisibility}
|
||||
>
|
||||
</affine-outline-panel-body>
|
||||
<affine-outline-notice
|
||||
.noticeVisible=${this._noticeVisible}
|
||||
.toggleNotesSorting=${this._toggleNotesSorting}
|
||||
.setNoticeVisibility=${this._setNoticeVisibility}
|
||||
></affine-outline-notice>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _enableNotesSorting = false;
|
||||
|
||||
@state()
|
||||
private accessor _noticeVisible = false;
|
||||
|
||||
@state()
|
||||
private accessor _showPreviewIcon = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: AffineEditorContainer;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fitPadding!: number[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_PANEL]: OutlinePanel;
|
||||
}
|
||||
}
|
||||