diff --git a/.vscode/launch.json b/.vscode/launch.json index cb08a80418..a8aa8e0737 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -300,22 +300,24 @@ "cwd": "${workspaceRoot}/dev/tool" }, { - "name": "Debug tool upgrade PG(tests)", + "name": "Debug tool upgrade PG(Cockroach)", "type": "node", "request": "launch", - "args": ["src/__start.ts", "upgrade", "--force"], + "args": ["src/__start.ts", "upgrade-workspace", "w-haiodo-alex-staff-c-673ee7ab-87df5406ea-2b8b4d" ], "env": { "SERVER_SECRET": "secret", "MINIO_ACCESS_KEY": "minioadmin", "MINIO_SECRET_KEY": "minioadmin", - "MINIO_ENDPOINT": "localhost:9002", - "TRANSACTOR_URL": "ws://localhost:3334", - "ACCOUNT_DB_URL": "postgresql://postgres:example@localhost:5433", - "DB_URL": "postgresql://postgres:example@localhost:5433", - "MONGO_URL": "mongodb://localhost:27018", - "ACCOUNTS_URL": "http://localhost:3003", + "MINIO_ENDPOINT": "localhost:9000", + "TRANSACTOR_URL": "ws://localhost:3332", + "ACCOUNTS_URL": "http://localhost:3000", + "ACCOUNT_DB_URL": "mongodb://localhost:27017", + // "ACCOUNT_DB_URL": "postgresql://postgres:example@localhost:5433", + // "DB_URL": "postgresql://postgres:example@localhost:5433", + "DB_URL": "postgresql://root@host.docker.internal:26257/defaultdb?sslmode=disable", + "MONGO_URL": "mongodb://localhost:27017", "TELEGRAM_DATABASE": "telegram-service", - "ELASTIC_URL": "http://localhost:9201", + "ELASTIC_URL": "http://localhost:9200", "REKONI_URL": "http://localhost:4004", "MODEL_VERSION": "0.6.287" }, diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 13b158e773..bd4147298b 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -22208,7 +22208,7 @@ packages: dev: false file:projects/collaboration.tgz(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-krhgq1XiDnWKIP/HUM8VQgEzXdxLNfDf68lZgDl/Yl2tEFUu8yLYpzd1qWVMkl8N0dXyGts+DEFC7Ntns48lgA==, tarball: file:projects/collaboration.tgz} + resolution: {integrity: sha512-aJ4uMSpM7IB3wgrjVKYm4jR3IeBYSaFvYZZFjxkriMD1fAxvjr/WpKUWxQy0q2x3gZb4SoGLoiX2d2qj2/hdhA==, tarball: file:projects/collaboration.tgz} id: file:projects/collaboration.tgz name: '@rush-temp/collaboration' version: 0.0.0 @@ -24713,7 +24713,7 @@ packages: dev: false file:projects/model-document.tgz: - resolution: {integrity: sha512-tSr57oIXY1fECAB/axaDBJLSh/RVC4BXacjVHQ4wx3y+buoNngZoX9kpJsbNxEjCpW8yyhWwO1+sseyBi9RJdg==, tarball: file:projects/model-document.tgz} + resolution: {integrity: sha512-5JcKBBX19mvQXZAg2p1z/qMSYqiR7py8mtNiHLaWKQpjHhMynutkkwGsnGq36Hb3Af8VtQiVxBZEq4rtcoph1Q==, tarball: file:projects/model-document.tgz} name: '@rush-temp/model-document' version: 0.0.0 dependencies: @@ -28589,7 +28589,7 @@ packages: dev: false file:projects/server-backup.tgz(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-e0MNgA1LeSOikxK3b6X/HMuzpyQ4MjAoJtHi84X28nxD+49sEy1NctCTptyqz2qWWicAZAghKV46Qi9N9+RnEw==, tarball: file:projects/server-backup.tgz} + resolution: {integrity: sha512-uuTU9Pa0R+AlAtxZo0mStW6hAnk/Ca3B++AoFAE5WDapBBxT0mVNEoDcAd0ATDKea0fo8mbRB4blnuoQa06NbA==, tarball: file:projects/server-backup.tgz} id: file:projects/server-backup.tgz name: '@rush-temp/server-backup' version: 0.0.0 @@ -28604,6 +28604,7 @@ packages: eslint-plugin-import: 2.29.1(eslint@8.56.0) eslint-plugin-n: 15.7.0(eslint@8.56.0) eslint-plugin-promise: 6.1.1(eslint@8.56.0) + fast-equals: 5.0.1 jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) prettier: 3.2.5 prettier-plugin-svelte: 3.2.1(prettier@3.2.5)(svelte@4.2.11) diff --git a/desktop/src/main/backup.ts b/desktop/src/main/backup.ts index 7857638632..25e0ba7e18 100644 --- a/desktop/src/main/backup.ts +++ b/desktop/src/main/backup.ts @@ -42,7 +42,8 @@ async function doBackup (dirName: string, token: string, endpoint: string, works ctx.info('do backup', { workspace, endpoint }) await backup(ctx, endpoint, wsid, storage, { force: true, - recheck: false, + freshBackup: false, + clean: false, skipDomains: [], timeout: 0, connectTimeout: 60 * 1000, diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 789a97df91..d96c6f04a9 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -639,6 +639,7 @@ export function devTool ( }, cmd.region, true, + true, 5000, // 5 gigabytes per blob async (storage, workspaceStorage) => { if (cmd.remove) { @@ -710,7 +711,8 @@ export function devTool ( }) }, cmd.region, - true, + false, + false, 100 ) ) { @@ -930,7 +932,8 @@ export function devTool ( ) .option('-bl, --blobLimit ', 'A blob size limit in megabytes (default 15mb)', '15') .option('-f, --force', 'Force backup', false) - .option('-c, --recheck', 'Force hash recheck on server', false) + .option('-f, --fresh', 'Force fresh backup', false) + .option('-c, --clean', 'Force clean of old backup files, only with fresh backup option', false) .option('-t, --timeout ', 'Connect timeout in seconds', '30') .action( async ( @@ -939,7 +942,8 @@ export function devTool ( cmd: { skip: string force: boolean - recheck: boolean + fresh: boolean + clean: boolean timeout: string include: string blobLimit: string @@ -951,7 +955,8 @@ export function devTool ( const endpoint = await getTransactorEndpoint(generateToken(systemAccountEmail, wsid), 'external') await backup(toolCtx, endpoint, wsid, storage, { force: cmd.force, - recheck: cmd.recheck, + freshBackup: cmd.fresh, + clean: cmd.clean, include: cmd.include === '*' ? undefined : new Set(cmd.include.split(';').map((it) => it.trim())), skipDomains: (cmd.skip ?? '').split(';').map((it) => it.trim()), timeout: 0, diff --git a/models/all/src/index.ts b/models/all/src/index.ts index d8faff2804..49c616188b 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -416,8 +416,8 @@ export default function buildModel (enabled: string[] = ['*'], disabled: string[ { label: testManagement.string.ConfigLabel, description: testManagement.string.ConfigDescription, - enabled: false, - beta: false, + enabled: true, + beta: true, classFilter: defaultFilter } ], diff --git a/models/core/src/migration.ts b/models/core/src/migration.ts index 4adc408598..6cae6354c7 100644 --- a/models/core/src/migration.ts +++ b/models/core/src/migration.ts @@ -21,9 +21,9 @@ import core, { DOMAIN_STATUS, DOMAIN_TX, generateId, - makeDocCollabId, makeCollabJsonId, makeCollabYdocId, + makeDocCollabId, MeasureMetricsContext, RateLimiter, type AnyAttribute, @@ -208,7 +208,7 @@ async function processMigrateContentFor ( const operations: { filter: MigrationDocumentQuery, update: MigrateUpdate }[] = [] for (const doc of docs) { - await rateLimiter.exec(async () => { + await rateLimiter.add(async () => { const update: MigrateUpdate = {} for (const attribute of attributes) { @@ -305,7 +305,7 @@ async function processMigrateJsonForDomain ( const operations: { filter: MigrationDocumentQuery, update: MigrateUpdate }[] = [] for (const doc of docs) { - await rateLimiter.exec(async () => { + await rateLimiter.add(async () => { const update = await processMigrateJsonForDoc(ctx, doc, attributes, client, storageAdapter) if (Object.keys(update).length > 0) { operations.push({ filter: { _id: doc._id }, update }) @@ -385,8 +385,8 @@ async function processMigrateJsonForDoc ( const unset = update.$unset ?? {} update.$unset = { ...unset, [attribute.name]: 1 } - } catch (err) { - ctx.warn('failed to process collaborative doc', { workspaceId, collabId, currentYdocId, err }) + } catch (err: any) { + ctx.warn('failed to process collaborative doc', { workspaceId, collabId, currentYdocId, err: err.message }) } } @@ -427,36 +427,43 @@ export const coreOperation: MigrateOperation = { state: 'remove-collection-txes', func: async (client) => { let processed = 0 + let last = 0 const iterator = await client.traverse>(DOMAIN_TX, { _class: 'core:class:TxCollectionCUD' as Ref> }) - while (true) { - const txes = await iterator.next(200) - if (txes === null || txes.length === 0) break - processed += txes.length - try { - await client.create( - DOMAIN_TX, - txes.map((tx) => { - const { collection, objectId, objectClass } = tx - return { - collection, - attachedTo: objectId, - attachedToClass: objectClass, - ...(tx as any).tx, - objectSpace: (tx as any).tx.objectSpace ?? tx.objectClass - } + try { + while (true) { + const txes = await iterator.next(1000) + if (txes === null || txes.length === 0) break + processed += txes.length + try { + await client.create( + DOMAIN_TX, + txes.map((tx) => { + const { collection, objectId, objectClass } = tx + return { + collection, + attachedTo: objectId, + attachedToClass: objectClass, + ...(tx as any).tx, + objectSpace: (tx as any).tx.objectSpace ?? tx.objectClass + } + }) + ) + await client.deleteMany(DOMAIN_TX, { + _id: { $in: txes.map((it) => it._id) } }) - ) - await client.deleteMany(DOMAIN_TX, { - _id: { $in: txes.map((it) => it._id) } - }) - } catch (err: any) { - console.error(err) + } catch (err: any) { + console.error(err) + } + if (last !== Math.round(processed / 1000)) { + last = Math.round(processed / 1000) + console.log('processed', processed) + } } - console.log('processed', processed) + } finally { + await iterator.close() } - await iterator.close() } }, { diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts index 997de8ae98..d22405c96e 100644 --- a/packages/core/src/__tests__/client.test.ts +++ b/packages/core/src/__tests__/client.test.ts @@ -120,7 +120,7 @@ describe('client', () => { }, close: async () => {}, - loadChunk: async (domain: Domain, idx?: number, recheck?: boolean) => ({ + loadChunk: async (domain: Domain, idx?: number) => ({ idx: -1, index: -1, docs: [], diff --git a/packages/core/src/__tests__/connection.ts b/packages/core/src/__tests__/connection.ts index e38a65cae6..17b549e10d 100644 --- a/packages/core/src/__tests__/connection.ts +++ b/packages/core/src/__tests__/connection.ts @@ -60,7 +60,7 @@ export async function connect (handler: (tx: Tx) => void): Promise {}, - loadChunk: async (domain: Domain, idx?: number, recheck?: boolean) => ({ + loadChunk: async (domain: Domain, idx?: number) => ({ idx: -1, index: -1, docs: [], diff --git a/packages/core/src/backup.ts b/packages/core/src/backup.ts index 56c827cc89..7e052bb97c 100644 --- a/packages/core/src/backup.ts +++ b/packages/core/src/backup.ts @@ -17,7 +17,7 @@ export interface DocChunk { * @public */ export interface BackupClient { - loadChunk: (domain: Domain, idx?: number, recheck?: boolean) => Promise + loadChunk: (domain: Domain, idx?: number) => Promise closeChunk: (idx: number) => Promise loadDocs: (domain: Domain, docs: Ref[]) => Promise diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 56c14d31e4..fc7e458ee3 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -178,8 +178,8 @@ class ClientImpl implements AccountClient, BackupClient { await this.conn.close() } - async loadChunk (domain: Domain, idx?: number, recheck?: boolean): Promise { - return await this.conn.loadChunk(domain, idx, recheck) + async loadChunk (domain: Domain, idx?: number): Promise { + return await this.conn.loadChunk(domain, idx) } async closeChunk (idx: number): Promise { diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index d0956761a5..5279bc30f9 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -25,7 +25,6 @@ import type { WorkspaceIdWithUrl } from './utils' export interface DocInfo { id: string hash: string - size: number // Aprox size } /** * @public @@ -68,8 +67,7 @@ export interface SessionData { */ export interface LowLevelStorage { // Low level streaming API to retrieve information - // If recheck is passed, all %hash% for documents, will be re-calculated. - find: (ctx: MeasureContext, domain: Domain, recheck?: boolean) => StorageIterator + find: (ctx: MeasureContext, domain: Domain) => StorageIterator // Load passed documents from domain load: (ctx: MeasureContext, domain: Domain, docs: Ref[]) => Promise diff --git a/packages/model/src/migration.ts b/packages/model/src/migration.ts index 1159cbeb8c..d59e3b2035 100644 --- a/packages/model/src/migration.ts +++ b/packages/model/src/migration.ts @@ -159,6 +159,7 @@ export async function tryMigrate (client: MigrationClient, plugin: string, migra for (const migration of migrations) { if (states.has(migration.state)) continue try { + console.log('running migration', plugin, migration.state) await migration.func(client) } catch (err: any) { console.error(err) diff --git a/packages/presentation/src/components/DrawingBoard.svelte b/packages/presentation/src/components/DrawingBoard.svelte index afbae21946..11395bf3b7 100644 --- a/packages/presentation/src/components/DrawingBoard.svelte +++ b/packages/presentation/src/components/DrawingBoard.svelte @@ -16,7 +16,7 @@ import { Analytics } from '@hcengineering/analytics' import { resizeObserver } from '@hcengineering/ui' import { onDestroy } from 'svelte' - import { drawing, type DrawingCmd, type DrawingData, type DrawingTool } from '../drawing' + import { drawing, type DrawingCmd, type DrawingData, type DrawingTool, type DrawTextCmd } from '../drawing' import DrawingBoardToolbar from './DrawingBoardToolbar.svelte' export let active = false @@ -30,6 +30,7 @@ let penColor: string let penWidth: number let eraserWidth: number + let fontSize: number let commands: DrawingCmd[] | undefined let board: HTMLDivElement let toolbar: HTMLDivElement @@ -37,6 +38,7 @@ let oldReadonly: boolean let oldDrawings: DrawingData[] let modified = false + let changingCmdIndex: number | undefined $: updateToolbarPosition(readonly, board, toolbar) $: updateEditableState(drawings, readonly) @@ -68,6 +70,7 @@ } else { commands = undefined } + changingCmdIndex = undefined oldDrawings = drawings oldReadonly = readonly } @@ -99,6 +102,40 @@ } } + function addCommand (cmd: DrawingCmd): void { + if (commands !== undefined) { + commands = [...commands, cmd] + changingCmdIndex = undefined + modified = true + } + } + + function showCommandProps (index: number): void { + changingCmdIndex = index + const anyCmd = commands?.[index] + if (anyCmd?.type === 'text') { + const cmd = anyCmd as DrawTextCmd + penColor = cmd.color + fontSize = cmd.fontSize + } + } + + function changeCommand (index: number, cmd: DrawingCmd): void { + if (commands !== undefined) { + commands = commands.map((c, i) => (i === index ? cmd : c)) + changingCmdIndex = undefined + modified = true + } + } + + function deleteCommand (index: number): void { + if (commands !== undefined) { + commands = commands.filter((_, i) => i !== index) + changingCmdIndex = undefined + modified = true + } + } + onDestroy(() => { saveDrawing() }) @@ -121,9 +158,15 @@ penColor, penWidth, eraserWidth, - cmdAdded: () => { - modified = true - } + fontSize, + changingCmdIndex, + cmdAdded: addCommand, + cmdChanging: showCommandProps, + cmdChanged: changeCommand, + cmdUnchanged: () => { + changingCmdIndex = undefined + }, + cmdDeleted: deleteCommand }} > {#if !readonly} @@ -134,6 +177,7 @@ bind:penColor bind:penWidth bind:eraserWidth + bind:fontSize on:clear={() => { commands = [] modified = true diff --git a/packages/presentation/src/components/DrawingBoardToolbar.svelte b/packages/presentation/src/components/DrawingBoardToolbar.svelte index 856b96405e..8106dad2f6 100644 --- a/packages/presentation/src/components/DrawingBoardToolbar.svelte +++ b/packages/presentation/src/components/DrawingBoardToolbar.svelte @@ -28,6 +28,7 @@ import { createEventDispatcher, onMount } from 'svelte' import IconEraser from './icons/Eraser.svelte' import IconMove from './icons/Move.svelte' + import IconText from './icons/Text.svelte' import { DrawingTool } from '../drawing' import presentation from '../plugin' @@ -40,13 +41,15 @@ color: 'drawingBoard.color', colors: 'drawingBoard.colors', penWidth: 'drawingBoard.penWidth', - eraserWidth: 'drawingBoard.eraserWidth' + eraserWidth: 'drawingBoard.eraserWidth', + fontSize: 'drawingBoard.fontSize' } export let tool: DrawingTool = 'pen' export let penColor: string export let penWidth: number export let eraserWidth: number + export let fontSize: number export let placeInside = false export let showPanTool = false export let toolbar: HTMLDivElement | undefined @@ -130,8 +133,9 @@ if (!penColors.includes(penColor)) { penColor = penColors[0] ?? defaultColor } - penWidth = parseInt(localStorage.getItem(storageKey.penWidth) ?? '6') + penWidth = parseInt(localStorage.getItem(storageKey.penWidth) ?? '4') eraserWidth = parseInt(localStorage.getItem(storageKey.eraserWidth) ?? '50') + fontSize = parseInt(localStorage.getItem(storageKey.fontSize) ?? '20') }) function updatePenWidth (): void { @@ -141,12 +145,17 @@ function updateEraserWidth (): void { localStorage.setItem(storageKey.eraserWidth, eraserWidth.toString()) } + + function updateFontSize (): void { + localStorage.setItem(storageKey.fontSize, fontSize.toString()) + }
@@ -242,7 +276,7 @@ border-radius: var(--small-BorderRadius); border: 1px solid var(--theme-popup-divider); box-shadow: 0.05rem 0.05rem 0.25rem rgba(0, 0, 0, 0.2); - z-index: 1; + z-index: 10; } } diff --git a/packages/presentation/src/components/icons/Text.svelte b/packages/presentation/src/components/icons/Text.svelte new file mode 100644 index 0000000000..e3d4544542 --- /dev/null +++ b/packages/presentation/src/components/icons/Text.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/packages/presentation/src/drawing.ts b/packages/presentation/src/drawing.ts index fd459fda00..8d0ca8eac9 100644 --- a/packages/presentation/src/drawing.ts +++ b/packages/presentation/src/drawing.ts @@ -22,26 +22,43 @@ export interface DrawingProps { autoSize?: boolean imageWidth?: number imageHeight?: number - commandCount?: number commands: DrawingCmd[] offset?: Point tool?: DrawingTool penColor?: string penWidth?: number eraserWidth?: number + fontSize?: number defaultCursor?: string + changingCmdIndex?: number cmdAdded?: (cmd: DrawingCmd) => void + cmdChanging?: (index: number) => void + cmdUnchanged?: (index: number) => void + cmdChanged?: (index: number, cmd: DrawingCmd) => void + cmdDeleted?: (index: number) => void panned?: (offset: Point) => void } export interface DrawingCmd { + type: 'line' | 'text' +} + +export interface DrawTextCmd extends DrawingCmd { + text: string + pos: Point + fontSize: number + fontFace: string + color: string +} + +export interface DrawLineCmd extends DrawingCmd { lineWidth: number erasing: boolean penColor: string points: Point[] } -export type DrawingTool = 'pen' | 'erase' | 'pan' +export type DrawingTool = 'pen' | 'erase' | 'pan' | 'text' interface Point { x: number @@ -52,6 +69,12 @@ function avgPoint (p1: Point, p2: Point): Point { return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 } } +const maxTextLength = 500 + +const crossSvg = ` + +` + class DrawState { on = false tool: DrawingTool = 'pen' @@ -59,6 +82,8 @@ class DrawState { penWidth = 4 eraserWidth = 30 minLineLength = 6 + fontSize = 20 + fontFace = '"IBM Plex Sans"' center: Point = { x: 0, y: 0 } offset: Point = { x: 0, y: 0 } points: Point[] = [] @@ -78,16 +103,31 @@ class DrawState { } addPoint = (mouseX: number, mouseY: number): void => { - this.points.push({ - x: mouseX * this.scale.x - this.offset.x - this.center.x, - y: mouseY * this.scale.y - this.offset.y - this.center.y - }) + this.points.push(this.mouseToCanvasPoint({ x: mouseX, y: mouseY })) + } + + mouseToCanvasPoint = (mouse: Point): Point => { + return { + x: mouse.x * this.scale.x - this.offset.x - this.center.x, + y: mouse.y * this.scale.y - this.offset.y - this.center.y + } + } + + canvasToMousePoint = (canvas: Point): Point => { + return { + x: canvas.x / this.scale.x + this.offset.x + this.center.x, + y: canvas.y / this.scale.y + this.offset.y + this.center.y + } } isDrawingTool = (): boolean => { return this.tool === 'pen' || this.tool === 'erase' } + translateCtx = (): void => { + this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y) + } + drawLive = (x: number, y: number, lastPoint = false): void => { window.requestAnimationFrame(() => { if (!lastPoint || this.points.length > 1) { @@ -95,11 +135,11 @@ class DrawState { } const erasing = this.tool === 'erase' this.ctx.save() - this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y) + this.translateCtx() this.ctx.beginPath() this.ctx.lineCap = 'round' this.ctx.strokeStyle = this.penColor - this.ctx.lineWidth = (erasing ? this.eraserWidth : this.penWidth) * this.lineScale() + this.ctx.lineWidth = erasing ? this.eraserWidth : this.penWidth this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over' if (this.points.length === 1) { this.drawPoint(this.points[0], erasing) @@ -112,8 +152,16 @@ class DrawState { } drawCommand = (cmd: DrawingCmd): void => { + if (cmd.type === 'text') { + this.drawTextCommand(cmd as DrawTextCmd) + } else { + this.drawLineCommand(cmd as DrawLineCmd) + } + } + + drawLineCommand = (cmd: DrawLineCmd): void => { this.ctx.save() - this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y) + this.translateCtx() this.ctx.beginPath() this.ctx.lineCap = 'round' this.ctx.strokeStyle = cmd.penColor @@ -130,6 +178,39 @@ class DrawState { this.ctx.restore() } + drawTextCommand = (cmd: DrawTextCmd): void => { + const p = { ...cmd.pos } + this.ctx.save() + this.translateCtx() + this.ctx.font = `${cmd.fontSize}px ${cmd.fontFace}` + this.ctx.fillStyle = cmd.color + this.ctx.textBaseline = 'top' + const lines = cmd.text.split('\n').map((l) => l.trim()) + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + this.ctx.fillText(line, p.x, p.y) + p.y += cmd.fontSize + } + this.ctx.restore() + } + + isPointInText = (p: Point, cmd: DrawTextCmd): boolean => { + this.ctx.font = `${cmd.fontSize}px ${cmd.fontFace}` + const lines = cmd.text.split('\n').map((l) => l.trim()) + for (let i = 0; i < lines.length; i++) { + if (p.y < cmd.pos.y + i * cmd.fontSize || p.y > cmd.pos.y + (i + 1) * cmd.fontSize) { + continue + } + const line = lines[i] + const metrics = this.ctx.measureText(line) + if (p.x < cmd.pos.x || p.x > cmd.pos.x + metrics.width) { + continue + } + return true + } + return false + } + drawPoint = (p: Point, erasing: boolean): void => { let r = this.ctx.lineWidth / 2 if (!erasing) { @@ -211,16 +292,20 @@ export function drawing ( draw.penColor = props.penColor ?? draw.penColor draw.penWidth = props.penWidth ?? draw.penWidth draw.eraserWidth = props.eraserWidth ?? draw.eraserWidth + draw.fontSize = props.fontSize ?? draw.fontSize + draw.offset = props.offset ?? draw.offset updateCanvasCursor() + interface LiveTextBox { + pos: Point + box: HTMLDivElement + editor: HTMLDivElement + cmdIndex: number + } + let liveTextBox: LiveTextBox | undefined + let commands = props.commands - let commandCount = props.commandCount ?? 0 - replayCommands({ - offset: { - x: props.offset?.x ?? 0, - y: props.offset?.y ?? 0 - } - }) + replayCommands() const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { @@ -231,7 +316,7 @@ export function drawing ( canvas.height = Math.floor(entry.contentRect.height) draw.center.x = canvas.width / 2 draw.center.y = canvas.height / 2 - replayCommands({ offset: draw.offset }) + replayCommands() } else { draw.scale = { x: canvas.width / entry.contentRect.width, @@ -288,15 +373,16 @@ export function drawing ( if (draw.on && draw.tool === 'pan') { requestAnimationFrame(() => { - replayCommands({ - offset: { - x: draw.offset.x + x - prevPos.x, - y: draw.offset.y + y - prevPos.y - } - }) + draw.offset.x += x - prevPos.x + draw.offset.y += y - prevPos.y + replayCommands() prevPos = { x, y } }) } + + if (draw.on && draw.tool === 'text') { + prevPos = { x, y } + } } canvas.onpointerup = (e) => { @@ -308,9 +394,16 @@ export function drawing ( if (draw.on) { if (draw.isDrawingTool()) { draw.drawLive(e.offsetX, e.offsetY, true) - storeCommand() + storeLineCommand() } else if (draw.tool === 'pan') { props.panned?.(draw.offset) + } else if (draw.tool === 'text') { + if (liveTextBox !== undefined) { + storeTextCommand() + } else { + const cmdIndex = findTextCommand(prevPos) + props.cmdChanging?.(cmdIndex) + } } draw.on = false } @@ -328,16 +421,280 @@ export function drawing ( } } - function storeCommand (): void { + function findTextCommand (mousePos: Point): number { + const pos = draw.mouseToCanvasPoint(mousePos) + for (let i = commands.length - 1; i >= 0; i--) { + const anyCmd = commands[i] + if (anyCmd.type === 'text') { + const cmd = anyCmd as DrawTextCmd + if (draw.isPointInText(pos, cmd)) { + return i + } + } + } + return -1 + } + + function makeLiveTextBox (cmdIndex: number): void { + let pos = prevPos + let existingCmd: DrawTextCmd | undefined + if (cmdIndex >= 0 && commands[cmdIndex]?.type === 'text') { + existingCmd = commands[cmdIndex] as DrawTextCmd + pos = draw.canvasToMousePoint(existingCmd.pos) + } + + const padding = 6 + const handleSize = 14 + + const box = document.createElement('div') + box.style.zIndex = '1' + box.style.position = 'absolute' + box.style.left = `calc(${pos.x}px - ${padding}px)` + box.style.top = `calc(${pos.y}px - ${padding}px)` + box.style.border = '1px solid var(--theme-editbox-focus-border)' + box.style.borderRadius = 'var(--small-BorderRadius)' + box.style.padding = `${padding}px` + box.style.background = 'var(--theme-popup-header)' + box.addEventListener('mousedown', (e) => { + e.stopPropagation() + }) + box.addEventListener('click', (e) => { + e.stopPropagation() + editor.focus() + }) + + const editor = document.createElement('div') + editor.style.cursor = 'text' + editor.style.padding = '0' + editor.contentEditable = 'true' + editor.style.outline = 'none' + editor.style.minWidth = '2rem' + editor.style.whiteSpace = 'nowrap' + if (existingCmd !== undefined) { + editor.innerText = existingCmd.text + } + editor.addEventListener('input', (e) => { + if (editor.innerText.length > maxTextLength) { + e.preventDefault() + editor.innerText = editor.innerText.substring(0, maxTextLength) + moveCaretToEnd() + } + }) + editor.addEventListener('paste', (e) => { + e.preventDefault() + const selection = window.getSelection() + const text = (e.clipboardData?.getData('text/plain') ?? '').trim() + if (text.length === 0 || selection === null || selection.rangeCount === 0) { + return + } + let selectedLen = 0 + const range = selection.getRangeAt(0) + if (editor.contains(range.commonAncestorContainer)) { + selectedLen = range.endOffset - range.startOffset + } + const availableLen = maxTextLength - (selectedLen > 0 ? selectedLen : editor.innerText.length) + if (availableLen > 0) { + const lines = text.slice(0, availableLen).split('\n') + const pastedNode = document.createDocumentFragment() + for (let i = 0; i < lines.length; i++) { + pastedNode.appendChild(document.createTextNode(lines[i])) + if (i < lines.length - 1) { + pastedNode.appendChild(document.createElement('br')) + } + } + range.deleteContents() + range.insertNode(pastedNode) + // move caret to the end of pasted node + range.collapse(false) + selection.addRange(range) + } + }) + editor.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault() + if (liveTextBox !== undefined) { + const cmdIndex = liveTextBox.cmdIndex + if (cmdIndex >= 0) { + // reset changingCmdIndex in clients + setTimeout(() => { + props.cmdUnchanged?.(cmdIndex) + }, 0) + } + } + closeLiveTextBox() + replayCommands() + } else if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault() + storeTextCommand() + } + }) + box.appendChild(editor) + + const moveCaretToEnd = (): void => { + const selection = window.getSelection() + const range = document.createRange() + range.setStartAfter(editor.lastChild ?? editor) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + } + + const selectAll = (): void => { + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(editor) + selection?.removeAllRanges() + selection?.addRange(range) + } + + const makeHandle = (): HTMLDivElement => { + const handle = document.createElement('div') + handle.style.position = 'absolute' + handle.style.top = `-${handleSize / 2}px` + handle.style.width = `${handleSize}px` + handle.style.height = `${handleSize}px` + handle.style.color = 'var(--global-on-accent-TextColor)' + handle.style.background = 'var(--global-accent-IconColor)' + handle.style.borderRadius = '50%' + handle.style.display = 'flex' + handle.style.alignItems = 'center' + handle.style.justifyContent = 'center' + return handle + } + + const dragHandle = makeHandle() + dragHandle.style.left = `-${handleSize / 2}px` + dragHandle.style.cursor = 'grab' + dragHandle.addEventListener('pointerdown', (e) => { + e.preventDefault() + dragHandle.style.cursor = 'grabbing' + dragHandle.setPointerCapture(e.pointerId) + const x = e.clientX + const y = e.clientY + const dragStart = { x, y } + const pointerMove = (e: PointerEvent): void => { + e.preventDefault() + const x = e.clientX + const y = e.clientY + const dx = x - dragStart.x + const dy = y - dragStart.y + dragStart.x = x + dragStart.y = y + let newX = box.offsetLeft + dx + let newY = box.offsetTop + dy + // For screenshots the canvas always has the same size as the underlying image + // and we should not be able to drag the text box outside of the screenshot + if (props.autoSize !== true) { + newX = Math.max(0, newX) + newY = Math.max(0, newY) + if (newX + box.offsetWidth > node.clientWidth) { + newX = node.clientWidth - box.offsetWidth + } + if (newY + box.offsetHeight > node.clientHeight) { + newY = node.clientHeight - box.offsetHeight + } + } + box.style.left = `${newX}px` + box.style.top = `${newY}px` + if (liveTextBox !== undefined) { + liveTextBox.pos.x = newX + padding + liveTextBox.pos.y = newY + padding + } + } + const pointerUp = (e: PointerEvent): void => { + setTimeout(() => { + editor.focus() + }, 100) + e.preventDefault() + dragHandle.style.cursor = 'grab' + dragHandle.releasePointerCapture(e.pointerId) + dragHandle.removeEventListener('pointermove', pointerMove) + dragHandle.removeEventListener('pointerup', pointerUp) + } + dragHandle.addEventListener('pointermove', pointerMove) + dragHandle.addEventListener('pointerup', pointerUp) + }) + box.appendChild(dragHandle) + + const deleteButton = makeHandle() + deleteButton.style.right = `-${handleSize / 2}px` + deleteButton.style.cursor = 'pointer' + deleteButton.innerHTML = crossSvg + deleteButton.addEventListener('click', () => { + node.removeChild(box) + if (liveTextBox?.cmdIndex !== undefined) { + props.cmdDeleted?.(liveTextBox.cmdIndex) + } + liveTextBox = undefined + }) + box.appendChild(deleteButton) + + node.appendChild(box) + liveTextBox = { box, editor, pos, cmdIndex } + updateLiveTextBox() + setTimeout(() => { + editor.focus() + }, 100) + selectAll() + } + + function updateLiveTextBox (): void { + if (liveTextBox !== undefined) { + liveTextBox.editor.style.color = draw.penColor + liveTextBox.editor.style.lineHeight = `${draw.fontSize / draw.lineScale()}px` + liveTextBox.editor.style.fontSize = `${draw.fontSize / draw.lineScale()}px` + liveTextBox.editor.style.fontFamily = draw.fontFace + } + } + + function closeLiveTextBox (): void { + if (liveTextBox !== undefined) { + node.removeChild(liveTextBox.box) + liveTextBox = undefined + } + } + + function storeTextCommand (defer = false): void { + if (liveTextBox !== undefined) { + const text = (liveTextBox.editor.innerText ?? '').trim() + if (text !== '') { + const cmd: DrawTextCmd = { + type: 'text', + text, + pos: draw.mouseToCanvasPoint(liveTextBox.pos), + fontSize: draw.fontSize, + fontFace: draw.fontFace, + color: draw.penColor + } + const cmdIndex = liveTextBox.cmdIndex + const notify = (): void => { + if (cmdIndex >= 0) { + props.cmdChanged?.(cmdIndex, cmd) + } else { + props.cmdAdded?.(cmd) + } + } + if (defer) { + setTimeout(notify, 0) + } else { + notify() + } + } else { + props.cmdUnchanged?.(liveTextBox.cmdIndex) + } + } + } + + function storeLineCommand (): void { if (draw.points.length > 0) { const erasing = draw.tool === 'erase' - const cmd: DrawingCmd = { - lineWidth: (erasing ? draw.eraserWidth : draw.penWidth) * draw.lineScale(), + const cmd: DrawLineCmd = { + type: 'line', + lineWidth: erasing ? draw.eraserWidth : draw.penWidth, erasing, penColor: draw.penColor, points: draw.points } - commands.push(cmd) props.cmdAdded?.(cmd) } } @@ -360,28 +717,23 @@ export function drawing ( } else if (draw.tool === 'pan') { canvas.style.cursor = 'move' canvasCursor.style.visibility = 'hidden' + } else if (draw.tool === 'text') { + canvas.style.cursor = 'text' + canvasCursor.style.visibility = 'hidden' } else { canvas.style.cursor = 'default' canvasCursor.style.visibility = 'hidden' } } - function clearCanvas (): void { + function replayCommands (): void { draw.ctx.reset() - draw.offset = { x: 0, y: 0 } - } - - function replayCommands ({ offset, startIndex }: { offset?: Point, startIndex?: number } = {}): void { - if (startIndex === undefined || startIndex === 0) { - clearCanvas() - } - if (offset !== undefined) { - draw.offset = offset - } - for (let i = startIndex ?? 0; i < commands.length; i++) { + for (let i = 0; i < commands.length; i++) { + if (liveTextBox?.cmdIndex === i) { + continue + } draw.drawCommand(commands[i]) } - commandCount = commands.length } return { @@ -389,45 +741,65 @@ export function drawing ( update (props: DrawingProps) { let replay = false - let offset: Point | undefined - let startIndex: number | undefined - if (props.offset?.x !== draw.offset.x || props.offset?.y !== draw.offset.y) { - offset = props.offset + if (props.offset !== undefined && (props.offset.x !== draw.offset.x || props.offset.y !== draw.offset.y)) { + draw.offset = props.offset replay = true } if (commands !== props.commands) { commands = props.commands replay = true - } else if (props.commandCount !== undefined && props.commandCount !== commandCount) { - startIndex = commandCount - replay = true } let updateCursor = false + let updateTextBox = false if (draw.tool !== props.tool) { draw.tool = props.tool ?? 'pen' updateCursor = true } if (draw.penColor !== props.penColor) { draw.penColor = props.penColor ?? 'blue' + updateTextBox = true updateCursor = true } if (draw.penWidth !== props.penWidth) { - draw.penWidth = props.penWidth ?? 5 + draw.penWidth = props.penWidth ?? draw.penWidth updateCursor = true } if (draw.eraserWidth !== props.eraserWidth) { - draw.eraserWidth = props.eraserWidth ?? 5 + draw.eraserWidth = props.eraserWidth ?? draw.eraserWidth updateCursor = true } + if (draw.fontSize !== props.fontSize) { + draw.fontSize = props.fontSize ?? draw.fontSize + updateTextBox = true + } if (props.readonly !== readonly) { readonly = props.readonly ?? false updateCursor = true } + if (props.changingCmdIndex === undefined) { + if (liveTextBox !== undefined) { + storeTextCommand(true) + closeLiveTextBox() + replay = true + } + } else { + if (liveTextBox === undefined) { + makeLiveTextBox(props.changingCmdIndex) + replay = true + } else if (liveTextBox.cmdIndex !== props.changingCmdIndex) { + storeTextCommand(true) + closeLiveTextBox() + replay = true + } + } if (updateCursor) { updateCanvasCursor() } + if (updateTextBox) { + updateLiveTextBox() + } if (replay) { - replayCommands({ offset, startIndex }) + replayCommands() } } } diff --git a/packages/query/src/__tests__/connection.ts b/packages/query/src/__tests__/connection.ts index 8ce3b5da53..3cb9a5c4a6 100644 --- a/packages/query/src/__tests__/connection.ts +++ b/packages/query/src/__tests__/connection.ts @@ -82,7 +82,7 @@ FulltextStorage & { return {} }, close: async () => {}, - loadChunk: async (domain: Domain, idx?: number, recheck?: boolean) => ({ + loadChunk: async (domain: Domain, idx?: number) => ({ idx: -1, index: -1, docs: [], diff --git a/plugins/client-resources/src/connection.ts b/plugins/client-resources/src/connection.ts index f25a683be9..e7e673de42 100644 --- a/plugins/client-resources/src/connection.ts +++ b/plugins/client-resources/src/connection.ts @@ -663,8 +663,8 @@ class Connection implements ClientConnection { }) } - loadChunk (domain: Domain, idx?: number, recheck?: boolean): Promise { - return this.sendRequest({ method: 'loadChunk', params: [domain, idx, recheck] }) + loadChunk (domain: Domain, idx?: number): Promise { + return this.sendRequest({ method: 'loadChunk', params: [domain, idx] }) } closeChunk (idx: number): Promise { diff --git a/plugins/love-resources/src/components/widget/WidgetSwitcher.svelte b/plugins/love-resources/src/components/widget/WidgetSwitcher.svelte index 84d748d75d..a9257c8da6 100644 --- a/plugins/love-resources/src/components/widget/WidgetSwitcher.svelte +++ b/plugins/love-resources/src/components/widget/WidgetSwitcher.svelte @@ -16,17 +16,30 @@ import type { Asset, IntlString } from '@hcengineering/platform' import type { AnySvelteComponent } from '@hcengineering/ui' import { AppItem } from '@hcengineering/workbench-resources' + import { RoomType } from '@hcengineering/love' + import { currentRoom } from '../../stores' import { isConnected, isSharingEnabled, isCameraEnabled, isMicEnabled } from '../../utils' + import love from '../../plugin' export let label: IntlString export let icon: Asset | AnySvelteComponent export let selected: boolean = false export let size: 'small' | 'medium' | 'large' = 'small' + + $: allowCam = $currentRoom?.type === RoomType.Video