311 lines
6.6 KiB
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>
|