mirror of
https://github.com/hcengineering/platform.git
synced 2024-12-24 20:13:00 +03:00
Drawing overlay for image preview dialog (#7216)
Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>
This commit is contained in:
parent
a9a55a2287
commit
9bea2f6be6
@ -14,9 +14,10 @@
|
||||
//
|
||||
|
||||
import activity from '@hcengineering/activity'
|
||||
import type { Attachment, AttachmentMetadata, Photo, SavedAttachments } from '@hcengineering/attachment'
|
||||
import { IndexKind, type Blob, type Domain, type Ref } from '@hcengineering/core'
|
||||
import type { Attachment, AttachmentMetadata, Drawing, Photo, SavedAttachments } from '@hcengineering/attachment'
|
||||
import { IndexKind, type Blob, type Class, type Doc, type Domain, type Ref } from '@hcengineering/core'
|
||||
import {
|
||||
Hidden,
|
||||
Index,
|
||||
Model,
|
||||
Prop,
|
||||
@ -28,10 +29,11 @@ import {
|
||||
UX,
|
||||
type Builder
|
||||
} from '@hcengineering/model'
|
||||
import core, { TAttachedDoc } from '@hcengineering/model-core'
|
||||
import core, { TAttachedDoc, TDoc } from '@hcengineering/model-core'
|
||||
import preference, { TPreference } from '@hcengineering/model-preference'
|
||||
import view, { createAction } from '@hcengineering/model-view'
|
||||
import workbench, { WidgetType } from '@hcengineering/workbench'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/model-presentation'
|
||||
|
||||
import attachment from './plugin'
|
||||
@ -80,8 +82,24 @@ export class TSavedAttachments extends TPreference implements SavedAttachments {
|
||||
declare attachedTo: Ref<Attachment>
|
||||
}
|
||||
|
||||
@Model(attachment.class.Drawing, core.class.Doc, DOMAIN_ATTACHMENT)
|
||||
export class TDrawing extends TDoc implements Drawing {
|
||||
@Prop(TypeRef(core.class.Doc), getEmbeddedLabel('Parent'))
|
||||
@Index(IndexKind.Indexed)
|
||||
@Hidden()
|
||||
parent!: Ref<Doc>
|
||||
|
||||
@Prop(TypeRef(core.class.Class), getEmbeddedLabel('Parent class'))
|
||||
@Index(IndexKind.Indexed)
|
||||
@Hidden()
|
||||
parentClass!: Ref<Class<Doc>>
|
||||
|
||||
@Prop(TypeString(), getEmbeddedLabel('Content'))
|
||||
content?: string
|
||||
}
|
||||
|
||||
export function createModel (builder: Builder): void {
|
||||
builder.createModel(TAttachment, TPhoto, TSavedAttachments)
|
||||
builder.createModel(TAttachment, TDrawing, TPhoto, TSavedAttachments)
|
||||
|
||||
builder.mixin(attachment.class.Attachment, core.class.Class, view.mixin.ObjectPresenter, {
|
||||
presenter: attachment.component.AttachmentPresenter
|
||||
|
@ -33,7 +33,8 @@
|
||||
"Next": "Další",
|
||||
"FailedToPreview": "Náhled se nezdařil",
|
||||
"ContentType": "Typ obsahu",
|
||||
"ContentTypeNotSupported": "Náhled není dostupný pro tento typ obsahu"
|
||||
"ContentTypeNotSupported": "Náhled není dostupný pro tento typ obsahu",
|
||||
"StartDrawing": "Načmárejte"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Soubor je příliš velký"
|
||||
|
@ -33,7 +33,8 @@
|
||||
"Next": "Next",
|
||||
"FailedToPreview": "Failed to preview",
|
||||
"ContentType": "Content type",
|
||||
"ContentTypeNotSupported": "Preview is not available for this content type"
|
||||
"ContentTypeNotSupported": "Preview is not available for this content type",
|
||||
"StartDrawing": "Scribble over"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "File too large"
|
||||
|
@ -33,7 +33,8 @@
|
||||
"Next": "Siguiente",
|
||||
"FailedToPreview": "Error al previsualizar",
|
||||
"ContentType": "Tipo de contenido",
|
||||
"ContentTypeNotSupported": "La vista previa no está disponible para este tipo de contenido"
|
||||
"ContentTypeNotSupported": "La vista previa no está disponible para este tipo de contenido",
|
||||
"StartDrawing": "Garabatear encima"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Archivo demasiado grande"
|
||||
|
@ -33,7 +33,8 @@
|
||||
"Next": "Suivant",
|
||||
"FailedToPreview": "Échec de l'aperçu",
|
||||
"ContentType": "Type de contenu",
|
||||
"ContentTypeNotSupported": "L'aperçu n'est pas disponible pour ce type de contenu"
|
||||
"ContentTypeNotSupported": "L'aperçu n'est pas disponible pour ce type de contenu",
|
||||
"StartDrawing": "Gribouiller dessus"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Fichier trop volumineux"
|
||||
|
@ -33,7 +33,8 @@
|
||||
"Next": "Successivo",
|
||||
"FailedToPreview": "Impossibile mostrare l'anteprima",
|
||||
"ContentType": "Tipo di contenuto",
|
||||
"ContentTypeNotSupported": "Anteprima non disponibile per questo tipo di contenuto"
|
||||
"ContentTypeNotSupported": "Anteprima non disponibile per questo tipo di contenuto",
|
||||
"StartDrawing": "Scarabocchiare sopra"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "File troppo grande"
|
||||
|
@ -33,7 +33,8 @@
|
||||
"Next": "Seguinte",
|
||||
"FailedToPreview": "Falha ao pré-visualizar",
|
||||
"ContentType": "Tipo de conteúdo",
|
||||
"ContentTypeNotSupported": "A visualização não está disponível para este tipo de conteúdo"
|
||||
"ContentTypeNotSupported": "A visualização não está disponível para este tipo de conteúdo",
|
||||
"StartDrawing": "Scarabocchiare sopra"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Ficheiro demasiado grande"
|
||||
|
@ -33,7 +33,8 @@
|
||||
"Next": "Далее",
|
||||
"FailedToPreview": "Ошибка предпросмотра",
|
||||
"ContentType": "Тип контента",
|
||||
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента"
|
||||
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента",
|
||||
"StartDrawing": "Сделать набросок"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Файл слишком большой"
|
||||
|
@ -33,7 +33,8 @@
|
||||
"Next": "下一步",
|
||||
"FailedToPreview": "预览失败",
|
||||
"ContentType": "内容类型",
|
||||
"ContentTypeNotSupported": "此內容類型無法預覽"
|
||||
"ContentTypeNotSupported": "此內容類型無法預覽",
|
||||
"StartDrawing": "随意涂鸦"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "文件太大"
|
||||
|
147
packages/presentation/src/components/DrawingBoard.svelte
Normal file
147
packages/presentation/src/components/DrawingBoard.svelte
Normal file
@ -0,0 +1,147 @@
|
||||
<!--
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button, IconDelete, IconEdit, resizeObserver } from '@hcengineering/ui'
|
||||
import { drawing, type DrawingData, type DrawingTool } from '../drawing'
|
||||
import IconEraser from './icons/Eraser.svelte'
|
||||
|
||||
export let active = false
|
||||
export let readonly = false
|
||||
export let imageWidth: number | undefined
|
||||
export let imageHeight: number | undefined
|
||||
export let drawingData: DrawingData
|
||||
export let saveDrawing: (data: any) => Promise<void>
|
||||
|
||||
let drawingTool: DrawingTool = 'pen'
|
||||
let penColor = 'blue'
|
||||
const penColors = ['red', 'green', 'blue', 'white', 'black']
|
||||
|
||||
let board: HTMLDivElement
|
||||
let toolbar: HTMLDivElement
|
||||
let toolbarInside = false
|
||||
|
||||
$: updateToolbarPosition(readonly, board, toolbar)
|
||||
|
||||
function updateToolbarPosition (readonly: boolean, board: HTMLDivElement, toolbar: HTMLDivElement): void {
|
||||
if (!readonly && board?.offsetTop !== undefined && toolbar?.clientHeight !== undefined) {
|
||||
// TODO: There should be a generic solution
|
||||
// this only estimates a free room above the picture in FilePreviewPopup
|
||||
toolbarInside = board.offsetTop <= toolbar.clientHeight * 3
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if active}
|
||||
<div
|
||||
{...$$restProps}
|
||||
style:position="relative"
|
||||
bind:this={board}
|
||||
use:resizeObserver={() => {
|
||||
updateToolbarPosition(readonly, board, toolbar)
|
||||
}}
|
||||
use:drawing={{
|
||||
readonly,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
drawingData,
|
||||
saveDrawing,
|
||||
drawingTool,
|
||||
penColor
|
||||
}}
|
||||
>
|
||||
{#if !readonly}
|
||||
<div class="toolbar" class:inside={toolbarInside} bind:this={toolbar}>
|
||||
<Button
|
||||
icon={IconDelete}
|
||||
kind="icon"
|
||||
on:click={() => {
|
||||
drawingData = {}
|
||||
}}
|
||||
/>
|
||||
<div class="divider buttons-divider" />
|
||||
<Button
|
||||
icon={IconEdit}
|
||||
kind="icon"
|
||||
selected={drawingTool === 'pen'}
|
||||
on:click={() => {
|
||||
drawingTool = 'pen'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={IconEraser}
|
||||
kind="icon"
|
||||
selected={drawingTool === 'erase'}
|
||||
on:click={() => {
|
||||
drawingTool = 'erase'
|
||||
}}
|
||||
/>
|
||||
<div class="divider buttons-divider" />
|
||||
{#each penColors as color}
|
||||
<Button
|
||||
kind="icon"
|
||||
selected={penColor === color}
|
||||
on:click={() => {
|
||||
penColor = color
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot="content"
|
||||
class="colorIcon"
|
||||
class:emphasized={color === 'white' || color === 'black'}
|
||||
style:background={color}
|
||||
/>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
bottom: 100%;
|
||||
|
||||
&.inside {
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
bottom: unset;
|
||||
background-color: var(--theme-navpanel-color);
|
||||
border-radius: var(--small-BorderRadius);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.colorIcon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin: -3px;
|
||||
|
||||
&.emphasized {
|
||||
box-shadow: 0px 0px 3px 0px var(--theme-button-contrast-enabled);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 5px;
|
||||
}
|
||||
</style>
|
@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { type Blob, type Ref } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import { Dialog, tooltip } from '@hcengineering/ui'
|
||||
import { Button, Dialog, IconEdit, tooltip } from '@hcengineering/ui'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
|
||||
import { BlobMetadata } from '../types'
|
||||
@ -36,13 +36,38 @@
|
||||
export let fullSize = false
|
||||
export let showIcon = true
|
||||
|
||||
let drawingLoading = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
onMount(() => {
|
||||
if (fullSize) {
|
||||
dispatch('fullsize')
|
||||
}
|
||||
if (props.drawingAvailable === true) {
|
||||
loadDrawings(props.loadDrawings)
|
||||
}
|
||||
})
|
||||
|
||||
function toggleDrawingEdit (): void {
|
||||
const editable = props.drawingEditable === true
|
||||
props = { ...props, drawingEditable: !editable }
|
||||
}
|
||||
|
||||
function loadDrawings (load: () => Promise<any>): void {
|
||||
if (load !== undefined) {
|
||||
drawingLoading = true
|
||||
load()
|
||||
.then((result) => {
|
||||
drawingLoading = false
|
||||
props.drawingData = result
|
||||
})
|
||||
.catch((error) => {
|
||||
drawingLoading = false
|
||||
console.error('Failed to load drawings for file', file, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionContext context={{ mode: 'browser' }} />
|
||||
@ -65,6 +90,15 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="utils">
|
||||
{#if props.drawingAvailable === true}
|
||||
<Button
|
||||
icon={IconEdit}
|
||||
kind="icon"
|
||||
disabled={drawingLoading}
|
||||
showTooltip={{ label: presentation.string.StartDrawing }}
|
||||
on:click={toggleDrawingEdit}
|
||||
/>
|
||||
{/if}
|
||||
<DownloadFileButton {name} {file} />
|
||||
<ComponentExtensions
|
||||
extension={presentation.extension.FilePreviewPopupActions}
|
||||
|
10
packages/presentation/src/components/icons/Eraser.svelte
Normal file
10
packages/presentation/src/components/icons/Eraser.svelte
Normal file
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
export let size: 'small' | 'medium' | 'large'
|
||||
const fill: string = 'currentColor'
|
||||
</script>
|
||||
|
||||
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8.086 2.207a2 2 0 012.828 0l3.879 3.879a2 2 0 010 2.828l-5.5 5.5A2 2 0 017.879 15H5.12a2 2 0 01-1.414-.586l-2.5-2.5a2 2 0 010-2.828l6.879-6.879zm.66 11.34L3.453 8.254 1.914 9.793a1 1 0 000 1.414l2.5 2.5a1 1 0 00.707.293H7.88a1 1 0 00.707-.293l.16-.16z"
|
||||
/>
|
||||
</svg>
|
398
packages/presentation/src/drawing.ts
Normal file
398
packages/presentation/src/drawing.ts
Normal file
@ -0,0 +1,398 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
export interface DrawingData {
|
||||
id?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface DrawingProps {
|
||||
readonly?: boolean
|
||||
imageWidth?: number
|
||||
imageHeight?: number
|
||||
drawingData?: DrawingData
|
||||
saveDrawing?: (data: any) => Promise<void>
|
||||
|
||||
drawingTool?: DrawingTool
|
||||
penColor?: string
|
||||
}
|
||||
|
||||
interface DrawCmd {
|
||||
lineWidth: number
|
||||
erasing: boolean
|
||||
penColor: string
|
||||
points: Point[]
|
||||
}
|
||||
|
||||
export type DrawingTool = 'pen' | 'erase' | 'pan'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function avgPoint (p1: Point, p2: Point): Point {
|
||||
return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
|
||||
}
|
||||
|
||||
class DrawState {
|
||||
on = false
|
||||
tool: DrawingTool = 'pen'
|
||||
penColor = 'blue'
|
||||
penWidth = 4
|
||||
eraserWidth = 30
|
||||
minLineLength = 6
|
||||
offset: Point = { x: 0, y: 0 }
|
||||
points: Point[] = []
|
||||
scale: Point = { x: 1, y: 1 }
|
||||
ctx: CanvasRenderingContext2D
|
||||
|
||||
constructor (ctx: CanvasRenderingContext2D) {
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
cursorWidth = (): number => {
|
||||
return Math.max(8, this.tool === 'erase' ? this.eraserWidth : this.penWidth)
|
||||
}
|
||||
|
||||
lineScale = (): number => {
|
||||
return (this.scale.x + this.scale.y) / 2
|
||||
}
|
||||
|
||||
addPoint = (mouseX: number, mouseY: number): void => {
|
||||
this.points.push({
|
||||
x: mouseX * this.scale.x - this.offset.x,
|
||||
y: mouseY * this.scale.y - this.offset.y
|
||||
})
|
||||
}
|
||||
|
||||
isDrawingTool = (): boolean => {
|
||||
return this.tool === 'pen' || this.tool === 'erase'
|
||||
}
|
||||
|
||||
drawLive = (x: number, y: number, lastPoint = false): void => {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!lastPoint || this.points.length > 1) {
|
||||
this.addPoint(x, y)
|
||||
}
|
||||
const erasing = this.tool === 'erase'
|
||||
this.ctx.beginPath()
|
||||
this.ctx.lineCap = 'round'
|
||||
this.ctx.strokeStyle = this.penColor
|
||||
this.ctx.lineWidth = (erasing ? this.eraserWidth : this.penWidth) * this.lineScale()
|
||||
this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'
|
||||
if (this.points.length === 1) {
|
||||
this.drawPoint(this.points[0], erasing)
|
||||
} else {
|
||||
this.drawSmoothSegment(this.points, this.points.length - 1, lastPoint)
|
||||
this.ctx.stroke()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
drawCommand = (cmd: DrawCmd): void => {
|
||||
this.ctx.beginPath()
|
||||
this.ctx.lineCap = 'round'
|
||||
this.ctx.strokeStyle = cmd.penColor
|
||||
this.ctx.lineWidth = cmd.lineWidth
|
||||
this.ctx.globalCompositeOperation = cmd.erasing ? 'destination-out' : 'source-over'
|
||||
if (cmd.points.length === 1) {
|
||||
this.drawPoint(cmd.points[0], cmd.erasing)
|
||||
} else {
|
||||
for (let i = 1; i < cmd.points.length; i++) {
|
||||
this.drawSmoothSegment(cmd.points, i, i === cmd.points.length - 1)
|
||||
}
|
||||
this.ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
drawPoint = (p: Point, erasing: boolean): void => {
|
||||
let r = this.ctx.lineWidth / 2
|
||||
if (!erasing) {
|
||||
// Single point looks too small compared to a line of the same width
|
||||
// So make it a bit biggers
|
||||
r *= 1.5
|
||||
}
|
||||
this.ctx.lineWidth = 0
|
||||
this.ctx.fillStyle = this.ctx.strokeStyle
|
||||
this.ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
|
||||
this.ctx.fill()
|
||||
}
|
||||
|
||||
drawSmoothSegment = (points: Point[], index: number, lastPoint: boolean): void => {
|
||||
const curPos = points[index]
|
||||
const prevPos = points[index - 1]
|
||||
const avg = avgPoint(prevPos, curPos)
|
||||
if (index === 1) {
|
||||
this.ctx.moveTo(prevPos.x, prevPos.y)
|
||||
if (lastPoint) {
|
||||
this.ctx.lineTo(curPos.x, curPos.y)
|
||||
} else {
|
||||
this.ctx.quadraticCurveTo(curPos.x, curPos.y, avg.x, avg.y)
|
||||
}
|
||||
} else {
|
||||
const prevAvg = avgPoint(points[index - 2], prevPos)
|
||||
this.ctx.moveTo(prevAvg.x, prevAvg.y)
|
||||
if (lastPoint) {
|
||||
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, curPos.x, curPos.y)
|
||||
} else {
|
||||
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, avg.x, avg.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawing (node: HTMLElement, props: DrawingProps): any {
|
||||
if (
|
||||
props.imageWidth === undefined ||
|
||||
props.imageHeight === undefined ||
|
||||
node.clientWidth === undefined ||
|
||||
node.clientHeight === undefined
|
||||
) {
|
||||
console.error('Failed to create drawing: image size is not specified')
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.left = '0'
|
||||
canvas.style.top = '0'
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.width = props.imageWidth
|
||||
canvas.height = props.imageHeight
|
||||
node.appendChild(canvas)
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx === null) {
|
||||
console.error('Failed to create drawing: unable to get 2d canvas context')
|
||||
node.removeChild(canvas)
|
||||
return
|
||||
}
|
||||
|
||||
const canvasCursor = document.createElement('div')
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
canvasCursor.style.position = 'absolute'
|
||||
canvasCursor.style.borderRadius = '50%'
|
||||
canvasCursor.style.cursor = 'none'
|
||||
canvasCursor.style.pointerEvents = 'none'
|
||||
node.appendChild(canvasCursor)
|
||||
|
||||
let readonly = props.readonly ?? false
|
||||
let prevPos: Point = { x: 0, y: 0 }
|
||||
|
||||
const draw = new DrawState(ctx)
|
||||
draw.tool = props.drawingTool ?? 'pan'
|
||||
draw.penColor = props.penColor ?? 'blue'
|
||||
updateCanvasCursor()
|
||||
|
||||
let modified = false
|
||||
let commands: DrawCmd[] = []
|
||||
let drawingData = props.drawingData
|
||||
parseData()
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === canvas) {
|
||||
draw.scale = {
|
||||
x: canvas.width / entry.contentRect.width,
|
||||
y: canvas.height / entry.contentRect.height
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
canvas.onpointerdown = (e) => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
canvas.setPointerCapture(e.pointerId)
|
||||
|
||||
const x = e.offsetX
|
||||
const y = e.offsetY
|
||||
|
||||
draw.on = true
|
||||
draw.points = []
|
||||
prevPos = { x, y }
|
||||
if (draw.isDrawingTool()) {
|
||||
draw.addPoint(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.onpointermove = (e) => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
|
||||
const x = e.offsetX
|
||||
const y = e.offsetY
|
||||
|
||||
if (draw.isDrawingTool()) {
|
||||
const w = draw.cursorWidth()
|
||||
canvasCursor.style.left = `${x - w / 2}px`
|
||||
canvasCursor.style.top = `${y - w / 2}px`
|
||||
if (draw.on) {
|
||||
if (Math.hypot(prevPos.x - x, prevPos.y - y) < draw.minLineLength) {
|
||||
return
|
||||
}
|
||||
draw.drawLive(x, y)
|
||||
prevPos = { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: if (draw.tool === 'pan')
|
||||
// Currently we only show drawing over attached images
|
||||
// Their sizes are fixed and no pan required
|
||||
}
|
||||
|
||||
canvas.onpointerup = (e) => {
|
||||
if (readonly) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
canvas.releasePointerCapture(e.pointerId)
|
||||
if (draw.on) {
|
||||
if (draw.isDrawingTool()) {
|
||||
draw.drawLive(e.offsetX, e.offsetY, true)
|
||||
storeCommand()
|
||||
}
|
||||
draw.on = false
|
||||
}
|
||||
}
|
||||
|
||||
canvas.onpointerenter = () => {
|
||||
if (!readonly && draw.isDrawingTool()) {
|
||||
canvasCursor.style.visibility = 'visible'
|
||||
}
|
||||
}
|
||||
|
||||
canvas.onpointerleave = () => {
|
||||
if (!readonly && draw.isDrawingTool()) {
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
function storeCommand (): void {
|
||||
if (draw.points.length > 1) {
|
||||
const erasing = draw.tool === 'erase'
|
||||
const cmd: DrawCmd = {
|
||||
lineWidth: (erasing ? draw.eraserWidth : draw.penWidth) * draw.lineScale(),
|
||||
erasing,
|
||||
penColor: draw.penColor,
|
||||
points: draw.points
|
||||
}
|
||||
commands.push(cmd)
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
function updateCanvasCursor (): void {
|
||||
if (readonly) {
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
canvas.style.cursor = 'default'
|
||||
} else if (draw.isDrawingTool()) {
|
||||
canvas.style.cursor = 'none'
|
||||
const erasing = draw.tool === 'erase'
|
||||
const w = draw.cursorWidth()
|
||||
canvasCursor.style.background = erasing ? 'none' : draw.penColor
|
||||
canvasCursor.style.border = erasing ? '1px solid #333' : 'none'
|
||||
canvasCursor.style.boxShadow = erasing ? '0px 0px 0px 1px #eee inset' : 'none'
|
||||
canvasCursor.style.width = `${w}px`
|
||||
canvasCursor.style.height = `${w}px`
|
||||
} else if (draw.tool === 'pan') {
|
||||
canvas.style.cursor = 'move'
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
} else {
|
||||
canvas.style.cursor = 'default'
|
||||
canvasCursor.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
function clearCanvas (): void {
|
||||
draw.ctx.reset()
|
||||
draw.offset = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function replayCommands (): void {
|
||||
draw.ctx.reset()
|
||||
for (const cmd of commands) {
|
||||
draw.drawCommand(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
function parseData (): void {
|
||||
clearCanvas()
|
||||
if (drawingData?.content !== undefined) {
|
||||
try {
|
||||
commands = JSON.parse(drawingData.content)
|
||||
replayCommands()
|
||||
} catch (error) {
|
||||
commands = []
|
||||
console.error('Failed to parse drawing content', error)
|
||||
}
|
||||
} else {
|
||||
commands = []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
update (props: DrawingProps) {
|
||||
if (drawingData !== props.drawingData) {
|
||||
// Currently it expectes only the empty data on update
|
||||
// which means we pressed the "Clear canvas" button
|
||||
// We don't support yet creation of multiple drawings for the same image
|
||||
// so preserve the id to continue editing the previous drawing
|
||||
const oldId = drawingData?.id
|
||||
drawingData = props.drawingData
|
||||
if (drawingData !== undefined) {
|
||||
drawingData.id = oldId
|
||||
}
|
||||
modified = true
|
||||
parseData()
|
||||
}
|
||||
if (draw.tool !== props.drawingTool) {
|
||||
draw.tool = props.drawingTool ?? 'pen'
|
||||
updateCanvasCursor()
|
||||
}
|
||||
if (draw.penColor !== props.penColor) {
|
||||
draw.penColor = props.penColor ?? 'blue'
|
||||
updateCanvasCursor()
|
||||
}
|
||||
if (props.readonly !== readonly) {
|
||||
readonly = props.readonly ?? false
|
||||
updateCanvasCursor()
|
||||
}
|
||||
},
|
||||
destroy () {
|
||||
if (props.saveDrawing === undefined) {
|
||||
console.log('Save drawing method is not provided')
|
||||
} else {
|
||||
if (modified && (commands.length > 0 || drawingData?.id !== undefined)) {
|
||||
const data: DrawingData = drawingData ?? {}
|
||||
data.content = JSON.stringify(commands)
|
||||
props.saveDrawing(data).catch((error) => {
|
||||
console.error('Failed to save drawing', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -48,6 +48,7 @@ export { default as SearchResult } from './components/SearchResult.svelte'
|
||||
export { default as LiteMessageViewer } from './components/LiteMessageViewer.svelte'
|
||||
export { default as DownloadFileButton } from './components/DownloadFileButton.svelte'
|
||||
export { default as FileTypeIcon } from './components/FileTypeIcon.svelte'
|
||||
export { default as DrawingBoard } from './components/DrawingBoard.svelte'
|
||||
export { default } from './plugin'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@ -66,3 +67,4 @@ export * from './image'
|
||||
export * from './preview'
|
||||
export * from './sound'
|
||||
export * from './stats'
|
||||
export * from './drawing'
|
||||
|
@ -121,7 +121,8 @@ export default plugin(presentationId, {
|
||||
Next: '' as IntlString,
|
||||
FailedToPreview: '' as IntlString,
|
||||
ContentType: '' as IntlString,
|
||||
ContentTypeNotSupported: '' as IntlString
|
||||
ContentTypeNotSupported: '' as IntlString,
|
||||
StartDrawing: '' as IntlString
|
||||
},
|
||||
extension: {
|
||||
FilePreviewExtension: '' as ComponentExtensionId,
|
||||
|
@ -16,21 +16,15 @@
|
||||
import { type Attachment } from '@hcengineering/attachment'
|
||||
import type { WithLookup } from '@hcengineering/core'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import presentation, {
|
||||
FilePreviewPopup,
|
||||
canPreviewFile,
|
||||
getFileUrl,
|
||||
getPreviewAlignment,
|
||||
previewTypes
|
||||
} from '@hcengineering/presentation'
|
||||
import { IconMoreH, Menu, Action as UIAction, closeTooltip, showPopup, tooltip } from '@hcengineering/ui'
|
||||
import presentation, { canPreviewFile, getFileUrl, previewTypes } from '@hcengineering/presentation'
|
||||
import { IconMoreH, Menu, Action as UIAction, showPopup, tooltip } from '@hcengineering/ui'
|
||||
import view, { Action } from '@hcengineering/view'
|
||||
import workbench from '@hcengineering/workbench'
|
||||
|
||||
import AttachmentAction from './AttachmentAction.svelte'
|
||||
import FileDownload from './icons/FileDownload.svelte'
|
||||
import attachmentPlugin from '../plugin'
|
||||
import { openAttachmentInSidebar } from '../utils'
|
||||
import { openAttachmentInSidebar, showAttachmentPreviewPopup } from '../utils'
|
||||
|
||||
export let attachment: WithLookup<Attachment>
|
||||
export let isSaved = false
|
||||
@ -60,18 +54,7 @@
|
||||
window.open((e.target as HTMLAnchorElement).href, '_blank')
|
||||
return
|
||||
}
|
||||
closeTooltip()
|
||||
|
||||
showPopup(
|
||||
FilePreviewPopup,
|
||||
{
|
||||
file: attachment.file,
|
||||
contentType: attachment.type,
|
||||
name: attachment.name,
|
||||
metadata: attachment.metadata
|
||||
},
|
||||
getPreviewAlignment(attachment.type ?? '')
|
||||
)
|
||||
showAttachmentPreviewPopup(attachment)
|
||||
}
|
||||
|
||||
$: saveAttachmentAction = isSaved
|
||||
@ -92,7 +75,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const showMenu = (ev: Event) => {
|
||||
const showMenu = (ev: Event): void => {
|
||||
const actions: UIAction[] = []
|
||||
if (canPreview) {
|
||||
actions.push(openAction)
|
||||
|
@ -15,10 +15,9 @@
|
||||
<script lang="ts">
|
||||
import type { Attachment } from '@hcengineering/attachment'
|
||||
import type { WithLookup } from '@hcengineering/core'
|
||||
import { FilePreviewPopup, getFileUrl } from '@hcengineering/presentation'
|
||||
import { closeTooltip, showPopup } from '@hcengineering/ui'
|
||||
import { getFileUrl } from '@hcengineering/presentation'
|
||||
import filesize from 'filesize'
|
||||
import { getType } from '../utils'
|
||||
import { getType, showAttachmentPreviewPopup } from '../utils'
|
||||
|
||||
export let value: WithLookup<Attachment>
|
||||
|
||||
@ -41,17 +40,7 @@
|
||||
}
|
||||
|
||||
function openAttachment (): void {
|
||||
closeTooltip()
|
||||
showPopup(
|
||||
FilePreviewPopup,
|
||||
{
|
||||
file: value.file,
|
||||
contentType: value.type,
|
||||
name: value.name,
|
||||
metadata: value.metadata
|
||||
},
|
||||
isImage(value.type) ? 'centered' : 'float'
|
||||
)
|
||||
showAttachmentPreviewPopup(value)
|
||||
}
|
||||
$: src = getFileUrl(value.file, value.name)
|
||||
</script>
|
||||
|
@ -18,17 +18,16 @@
|
||||
import core, { type WithLookup } from '@hcengineering/core'
|
||||
import presentation, {
|
||||
canPreviewFile,
|
||||
FilePreviewPopup,
|
||||
getBlobRef,
|
||||
getFileUrl,
|
||||
previewTypes,
|
||||
sizeToWidth
|
||||
} from '@hcengineering/presentation'
|
||||
import { Label, closeTooltip, showPopup } from '@hcengineering/ui'
|
||||
import { Label } from '@hcengineering/ui'
|
||||
import { permissionsStore } from '@hcengineering/view-resources'
|
||||
import filesize from 'filesize'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { getType, openAttachmentInSidebar } from '../utils'
|
||||
import { getType, openAttachmentInSidebar, showAttachmentPreviewPopup } from '../utils'
|
||||
|
||||
import AttachmentName from './AttachmentName.svelte'
|
||||
|
||||
@ -78,18 +77,8 @@
|
||||
window.open((e.target as HTMLAnchorElement).href, '_blank')
|
||||
return
|
||||
}
|
||||
closeTooltip()
|
||||
if (value.type.startsWith('image/') || value.type.startsWith('video/') || value.type.startsWith('audio/')) {
|
||||
showPopup(
|
||||
FilePreviewPopup,
|
||||
{
|
||||
file: value.file,
|
||||
contentType: value.type,
|
||||
name: value.name,
|
||||
metadata: value.metadata
|
||||
},
|
||||
'centered'
|
||||
)
|
||||
showAttachmentPreviewPopup(value)
|
||||
} else {
|
||||
await openAttachmentInSidebar(value)
|
||||
}
|
||||
|
@ -15,14 +15,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Attachment } from '@hcengineering/attachment'
|
||||
import { FilePreviewPopup } from '@hcengineering/presentation'
|
||||
import { closeTooltip, showPopup } from '@hcengineering/ui'
|
||||
import { ListSelectionProvider } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { WithLookup } from '@hcengineering/core'
|
||||
|
||||
import { AttachmentImageSize } from '../types'
|
||||
import { getType } from '../utils'
|
||||
import { getType, showAttachmentPreviewPopup } from '../utils'
|
||||
import AttachmentActions from './AttachmentActions.svelte'
|
||||
import AttachmentImagePreview from './AttachmentImagePreview.svelte'
|
||||
import AttachmentPresenter from './AttachmentPresenter.svelte'
|
||||
@ -47,13 +45,8 @@
|
||||
<div
|
||||
class="content flex-center buttonContainer cursor-pointer"
|
||||
on:click={() => {
|
||||
closeTooltip()
|
||||
if (listProvider !== undefined) listProvider.updateFocus(value)
|
||||
const popupInfo = showPopup(
|
||||
FilePreviewPopup,
|
||||
{ file: value.file, name: value.name, contentType: value.type, metadata: value.metadata },
|
||||
value.type.startsWith('image/') ? 'centered' : 'float'
|
||||
)
|
||||
const popupInfo = showAttachmentPreviewPopup(value)
|
||||
dispatch('open', popupInfo.id)
|
||||
}}
|
||||
>
|
||||
|
@ -17,9 +17,10 @@
|
||||
import { Photo } from '@hcengineering/attachment'
|
||||
import { Class, Doc, Ref, Space, type WithLookup } from '@hcengineering/core'
|
||||
import { setPlatformStatus, unknownError } from '@hcengineering/platform'
|
||||
import { FilePreviewPopup, createQuery, getBlobRef, getClient, uploadFile } from '@hcengineering/presentation'
|
||||
import { Button, IconAdd, Label, Spinner, showPopup } from '@hcengineering/ui'
|
||||
import { createQuery, getBlobRef, getClient, uploadFile } from '@hcengineering/presentation'
|
||||
import { Button, IconAdd, Label, Spinner } from '@hcengineering/ui'
|
||||
import attachment from '../plugin'
|
||||
import { showAttachmentPreviewPopup } from '../utils'
|
||||
import UploadDuo from './icons/UploadDuo.svelte'
|
||||
|
||||
export let objectId: Ref<Doc>
|
||||
@ -90,11 +91,7 @@
|
||||
const el: HTMLElement = ev.currentTarget as HTMLElement
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
||||
if (item !== undefined) {
|
||||
showPopup(
|
||||
FilePreviewPopup,
|
||||
{ file: item.file, name: item.name, contentType: item.type, metadata: item.metadata },
|
||||
item.type.startsWith('image/') ? 'centered' : 'float'
|
||||
)
|
||||
showAttachmentPreviewPopup(item)
|
||||
} else {
|
||||
inputFile.click()
|
||||
}
|
||||
|
@ -14,18 +14,28 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type BlobMetadata, type Attachment } from '@hcengineering/attachment'
|
||||
import {
|
||||
import { type BlobMetadata, type Attachment, type Drawing } from '@hcengineering/attachment'
|
||||
import core, {
|
||||
type Blob,
|
||||
type Class,
|
||||
type TxOperations as Client,
|
||||
type Data,
|
||||
type Doc,
|
||||
type Ref,
|
||||
type Space
|
||||
type Space,
|
||||
type WithLookup
|
||||
} from '@hcengineering/core'
|
||||
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
|
||||
import { type FileOrBlob, getClient, getFileMetadata, uploadFile } from '@hcengineering/presentation'
|
||||
import {
|
||||
type DrawingData,
|
||||
type FileOrBlob,
|
||||
getClient,
|
||||
getFileMetadata,
|
||||
getPreviewAlignment,
|
||||
uploadFile,
|
||||
FilePreviewPopup
|
||||
} from '@hcengineering/presentation'
|
||||
import { closeTooltip, showPopup, type PopupResult } from '@hcengineering/ui'
|
||||
import workbench, { type WidgetTab } from '@hcengineering/workbench'
|
||||
import view from '@hcengineering/view'
|
||||
|
||||
@ -102,6 +112,7 @@ export function getType (type: string): 'image' | 'text' | 'json' | 'video' | 'a
|
||||
}
|
||||
|
||||
export async function openAttachmentInSidebar (value: Attachment): Promise<void> {
|
||||
closeTooltip()
|
||||
await openFilePreviewInSidebar(value.file, value.name, value.type, value.metadata)
|
||||
}
|
||||
|
||||
@ -134,3 +145,48 @@ export async function openFilePreviewInSidebar (
|
||||
}
|
||||
await createFn(widget, tab, true)
|
||||
}
|
||||
|
||||
export function showAttachmentPreviewPopup (value: WithLookup<Attachment>): PopupResult {
|
||||
const props: Record<string, any> = {}
|
||||
|
||||
if (value?.type?.startsWith('image/')) {
|
||||
props.drawingAvailable = true
|
||||
props.loadDrawings = async (): Promise<DrawingData | undefined> => {
|
||||
const client = getClient()
|
||||
const drawing = await client.findOne(attachment.class.Drawing, { parent: value.file })
|
||||
if (drawing !== undefined) {
|
||||
return {
|
||||
id: drawing._id,
|
||||
content: drawing.content
|
||||
}
|
||||
}
|
||||
}
|
||||
props.saveDrawing = async (data: DrawingData): Promise<void> => {
|
||||
const client = getClient()
|
||||
if (data.id === undefined) {
|
||||
await client.createDoc(attachment.class.Drawing, value.space, {
|
||||
parent: value.file,
|
||||
parentClass: core.class.Blob,
|
||||
content: data.content
|
||||
})
|
||||
} else {
|
||||
await client.updateDoc(attachment.class.Drawing, value.space, data.id as Ref<Drawing>, {
|
||||
content: data.content
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeTooltip()
|
||||
return showPopup(
|
||||
FilePreviewPopup,
|
||||
{
|
||||
file: value.file,
|
||||
contentType: value.type,
|
||||
name: value.name,
|
||||
metadata: value.metadata,
|
||||
props
|
||||
},
|
||||
getPreviewAlignment(value.type ?? '')
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
// Copyright © 2021 Hardcore Engineering Inc.
|
||||
// Copyright © 2021, 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import type { AttachedDoc, Blob, Class, Ref } from '@hcengineering/core'
|
||||
import type { AttachedDoc, Blob, Class, Doc, Ref } from '@hcengineering/core'
|
||||
import type { Asset, Plugin } from '@hcengineering/platform'
|
||||
import { IntlString, plugin, Resource } from '@hcengineering/platform'
|
||||
import type { Preference } from '@hcengineering/preference'
|
||||
@ -62,6 +62,15 @@ export interface SavedAttachments extends Preference {
|
||||
attachedTo: Ref<Attachment>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Drawing extends Doc {
|
||||
parent: Ref<Doc>
|
||||
parentClass: Ref<Class<Doc>>
|
||||
content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -81,6 +90,7 @@ export default plugin(attachmentId, {
|
||||
},
|
||||
class: {
|
||||
Attachment: '' as Ref<Class<Attachment>>,
|
||||
Drawing: '' as Ref<Class<Drawing>>,
|
||||
Photo: '' as Ref<Class<Photo>>,
|
||||
SavedAttachments: '' as Ref<Class<SavedAttachments>>
|
||||
},
|
||||
|
@ -14,7 +14,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type Blob, type Ref } from '@hcengineering/core'
|
||||
import { getBlobRef, imageSizeToRatio, type BlobMetadata } from '@hcengineering/presentation'
|
||||
import { DrawingBoard, getBlobRef, imageSizeToRatio, type BlobMetadata } from '@hcengineering/presentation'
|
||||
import { Loading } from '@hcengineering/ui'
|
||||
|
||||
export let value: Ref<Blob>
|
||||
@ -22,6 +22,11 @@
|
||||
export let metadata: BlobMetadata | undefined
|
||||
export let fit: boolean = false
|
||||
|
||||
export let drawingAvailable: boolean
|
||||
export let drawingEditable: boolean
|
||||
export let drawingData: any
|
||||
export let saveDrawing: (data: any) => Promise<void>
|
||||
|
||||
$: originalWidth = metadata?.originalWidth
|
||||
$: originalHeight = metadata?.originalHeight
|
||||
$: pixelRatio = metadata?.pixelRatio ?? 1
|
||||
@ -41,6 +46,16 @@
|
||||
<Loading />
|
||||
</div>
|
||||
{/if}
|
||||
<DrawingBoard
|
||||
{imageWidth}
|
||||
{imageHeight}
|
||||
{drawingData}
|
||||
{saveDrawing}
|
||||
active={drawingAvailable && !loading}
|
||||
readonly={drawingAvailable && !drawingEditable}
|
||||
class="object-contain mx-auto"
|
||||
style={`max-width:${width};max-height:${height}`}
|
||||
>
|
||||
<img
|
||||
on:load={() => {
|
||||
loading = false
|
||||
@ -53,4 +68,5 @@
|
||||
alt={name}
|
||||
style:height={loading ? '0' : ''}
|
||||
/>
|
||||
</DrawingBoard>
|
||||
{/await}
|
||||
|
Loading…
Reference in New Issue
Block a user