Drawing overlay for image preview dialog (#7216)

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>
This commit is contained in:
Chunosov 2024-11-22 09:29:15 +07:00 committed by GitHub
parent a9a55a2287
commit 9bea2f6be6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 749 additions and 98 deletions

View File

@ -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

View File

@ -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ý"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -33,7 +33,8 @@
"Next": "Далее",
"FailedToPreview": "Ошибка предпросмотра",
"ContentType": "Тип контента",
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента"
"ContentTypeNotSupported": "Предварительный просмотр недоступен для этого типа контента",
"StartDrawing": "Сделать набросок"
},
"status": {
"FileTooLarge": "Файл слишком большой"

View File

@ -33,7 +33,8 @@
"Next": "下一步",
"FailedToPreview": "预览失败",
"ContentType": "内容类型",
"ContentTypeNotSupported": "此內容類型無法預覽"
"ContentTypeNotSupported": "此內容類型無法預覽",
"StartDrawing": "随意涂鸦"
},
"status": {
"FileTooLarge": "文件太大"

View 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>

View File

@ -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}

View 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>

View 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)
})
}
}
}
}
}

View File

@ -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'

View File

@ -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,

View File

@ -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)

View File

@ -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>

View File

@ -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)
}

View File

@ -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)
}}
>

View File

@ -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()
}

View File

@ -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 ?? '')
)
}

View File

@ -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>>
},

View File

@ -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,16 +46,27 @@
<Loading />
</div>
{/if}
<img
on:load={() => {
loading = false
}}
<DrawingBoard
{imageWidth}
{imageHeight}
{drawingData}
{saveDrawing}
active={drawingAvailable && !loading}
readonly={drawingAvailable && !drawingEditable}
class="object-contain mx-auto"
style:max-width={width}
style:max-height={height}
src={blobRef.src}
srcset={blobRef.srcset}
alt={name}
style:height={loading ? '0' : ''}
/>
style={`max-width:${width};max-height:${height}`}
>
<img
on:load={() => {
loading = false
}}
class="object-contain mx-auto"
style:max-width={width}
style:max-height={height}
src={blobRef.src}
srcset={blobRef.srcset}
alt={name}
style:height={loading ? '0' : ''}
/>
</DrawingBoard>
{/await}