upend/webui/src/lib/components/display/blobs/ImageViewer.svelte

311 lines
6.6 KiB
Svelte

<script lang="ts">
import type { IEntry } from '@upnd/upend/types';
import api from '$lib/api';
import { useEntity } from '$lib/entity';
import IconButton from '../../utils/IconButton.svelte';
import Spinner from '../../utils/Spinner.svelte';
import UpObject from '../UpObject.svelte';
import { ATTR_LABEL } from '@upnd/upend/constants';
import { i18n } from '../../../i18n';
export let address: string;
export let detail: boolean;
let editable = false;
const { entity } = useEntity(address);
let imageLoaded = false;
let imageEl: HTMLImageElement;
$: svg = Boolean($entity?.get('FILE_MIME')?.toString().includes('svg+xml'));
interface Annotorious {
addAnnotation: (a: W3cAnnotation) => void;
on: ((e: 'createAnnotation' | 'deleteAnnotation', c: (a: W3cAnnotation) => void) => void) &
((e: 'updateAnnotation', c: (a: W3cAnnotation, b: W3cAnnotation) => void) => void);
clearAnnotations: () => void;
readOnly: boolean;
destroy: () => void;
}
interface W3cAnnotation {
type: 'Annotation';
body: Array<{ type: 'TextualBody'; value: string; purpose: 'commenting' }>;
target: {
selector: {
type: 'FragmentSelector';
conformsTo: 'http://www.w3.org/TR/media-frags/';
value: string;
};
};
'@context': 'http://www.w3.org/ns/anno.jsonld';
id: string;
}
let anno: Annotorious;
$: if (anno) anno.readOnly = !editable;
$: if (anno) {
anno.clearAnnotations();
$entity?.backlinks
.filter((e) => e.attribute == 'ANNOTATES')
.forEach(async (e) => {
const annotation = await api.fetchEntity(e.entity);
if (annotation.get('W3C_FRAGMENT_SELECTOR')) {
anno.addAnnotation({
type: 'Annotation',
body: annotation.attr[ATTR_LABEL].map((e) => {
return {
type: 'TextualBody',
value: String(e.value.c),
purpose: 'commenting'
};
}),
target: {
selector: {
type: 'FragmentSelector',
conformsTo: 'http://www.w3.org/TR/media-frags/',
value: String(annotation.get('W3C_FRAGMENT_SELECTOR'))
}
},
'@context': 'http://www.w3.org/ns/anno.jsonld',
id: e.entity
});
}
});
}
$: hasAnnotations = $entity?.backlinks.some((e) => e.attribute === 'ANNOTATES');
let a8sLinkTarget: HTMLDivElement;
let a8sLinkAddress: string;
async function loaded() {
const { Annotorious } = await import('@recogito/annotorious');
if (anno) {
anno.destroy();
}
anno = new Annotorious({
image: imageEl,
drawOnSingleClick: true,
fragmentUnit: 'percent',
widgets: [
'COMMENT',
(info: { annotation: W3cAnnotation }) => {
a8sLinkAddress = info.annotation?.id;
return a8sLinkTarget;
}
]
});
anno.on('createAnnotation', async (annotation) => {
const [_, uuid] = await api.putEntry({
entity: {
t: 'Uuid'
}
});
annotation.id = uuid;
await api.putEntry([
{
entity: uuid,
attribute: 'ANNOTATES',
value: {
t: 'Address',
c: address
}
},
{
entity: uuid,
attribute: 'W3C_FRAGMENT_SELECTOR',
value: {
t: 'String',
c: annotation.target.selector.value
}
},
...annotation.body.map((body) => {
return {
entity: uuid,
attribute: ATTR_LABEL,
value: {
t: 'String',
c: body.value
}
} as IEntry;
})
]);
});
anno.on('updateAnnotation', async (annotation) => {
const annotationObject = await api.fetchEntity(annotation.id);
await Promise.all(
annotationObject.attr[ATTR_LABEL].concat(
annotationObject.attr['W3C_FRAGMENT_SELECTOR']
).map(async (e) => api.deleteEntry(e.address))
);
await api.putEntry([
{
entity: annotation.id,
attribute: 'W3C_FRAGMENT_SELECTOR',
value: {
t: 'String',
c: annotation.target.selector.value
}
},
...annotation.body.map((body) => {
return {
entity: annotation.id,
attribute: ATTR_LABEL,
value: {
t: 'String',
c: body.value
}
} as IEntry;
})
]);
});
anno.on('deleteAnnotation', async (annotation) => {
await api.deleteEntry(annotation.id);
});
imageLoaded = true;
}
function clicked() {
if (!document.fullscreenElement) {
if (!editable && !hasAnnotations) {
imageEl.requestFullscreen();
}
} else {
document.exitFullscreen();
}
}
let brightnesses = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5];
let brightnessIdx = 2;
function cycleBrightness() {
brightnessIdx++;
brightnessIdx = brightnessIdx % brightnesses.length;
}
let contrasts = [0.5, 0.75, 1, 1.25, 1.5];
let contrastsIdx = 2;
function cycleContrast() {
contrastsIdx++;
contrastsIdx = contrastsIdx % contrasts.length;
}
$: {
if (imageEl) {
const brightness = brightnesses[brightnessIdx];
const contrast = contrasts[contrastsIdx];
imageEl.style.filter = `brightness(${brightness}) contrast(${contrast})`;
}
}
</script>
<div class="image-viewer">
{#if !imageLoaded}
<Spinner centered />
{/if}
{#if imageLoaded}
<div class="toolbar">
<IconButton name="edit" on:click={() => (editable = !editable)} active={editable}>
{$i18n.t('Annotate')}
</IconButton>
<div class="image-controls">
<IconButton name="brightness-half" on:click={cycleBrightness}>
{$i18n.t('Brightness')}
</IconButton>
<IconButton name="tone" on:click={cycleContrast}>
{$i18n.t('Contrast')}
</IconButton>
</div>
</div>
{/if}
<div
class="image"
class:zoomable={!editable && !hasAnnotations}
on:click={clicked}
on:keydown={(ev) => {
if (ev.key === 'Enter') clicked();
}}
>
<img
class="preview-image"
src="{api.apiUrl}/{detail || svg ? 'raw' : 'thumb'}/{address}"
alt={address}
on:load={loaded}
bind:this={imageEl}
draggable="false"
/>
</div>
<div class="a8sUpLink" bind:this={a8sLinkTarget}>
{#if a8sLinkAddress}
<div class="link">
<UpObject link address={a8sLinkAddress} />
</div>
{/if}
</div>
</div>
<style global lang="scss">
@use '@recogito/annotorious/dist/annotorious.min.css';
.image-viewer {
display: flex;
flex-direction: column;
min-height: 0;
.image {
display: flex;
justify-content: center;
min-height: 0;
& > *,
img {
min-width: 0;
max-width: 100%;
min-height: 0;
max-height: 100%;
}
img {
margin: auto;
}
}
.toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 0.5em;
.image-controls {
display: flex;
}
}
.zoomable {
cursor: zoom-in;
}
img:fullscreen {
cursor: zoom-out;
}
}
.r6o-editor {
font-family: inherit;
}
.a8sUpLink {
text-align: initial;
.link {
margin: 0.5em 1em;
}
}
</style>