@@ -0,0 +1,3 @@ | |||
/dist | |||
/web-ext-artifacts | |||
/node_modules |
@@ -0,0 +1,77 @@ | |||
# Web Annotation Discovery WebExtension | |||
This browser extension implements the [Web Annotation Discovery][] mechanism, enabling you to discover annotations and subscribe to annotation ‘feeds’ while browsing the web; then view the collected annotations on other pages you visit. | |||
It also lets you create annotations yourself, and store/publish them on an annotation collection, so that other people view the notes you take on web pages. | |||
[Web Annotation Discovery]: https://code.treora.com/gerben/web-annotation-discovery | |||
## Status | |||
Minimum viable product, or primarily a proof-of-concept: This software demonstrates a mechanism for web annotation in practice, enabling people to try it out and hopefully move forward with the idea. | |||
The software can be used for collaborative annotation, but be prepared for possible technical glitches (including data loss or leakage), mediocre user experience, and awful interface design. | |||
## Try it out | |||
Install the extension in a recent Firefox(-based browser). It should work in Chromium(-based browsers) too, but is not as well-tested there. | |||
Note that you may need to jump some hoops to install an extension that has not been approved by the browser vendor itself. (An approved version may be available soon.) | |||
- Firefox (temporary): Go to `about:debugging`, choose ‘This Firefox’, and ‘Load Temporary Add-on…’. The extension will be removed again when you close the browser. | |||
- Firefox (permanently): You need a ‘developer’ or ‘nightly’ edition, set `xpinstall.signatures.required` to false in `about:config` and drag & drop the file onto the list at `about:addons`. | |||
- Chromium & co.: You need to enable ‘developer mode’ in `about:extensions`, and drag & drop the extension onto this page. It might be removed again when you close the browser. | |||
If one of those worked for you, fun can start. | |||
### Discovering & viewing annotations | |||
Visit a compatible annotation server. For now that means running a [Web Annotation Discovery Server][]. See its documentation for details. | |||
Then subscribe to a collection, visit one of the pages that any of its annotations target, and the yellow notes should show up on the right side. | |||
### Creating annotations | |||
If you have write access to an annotation collection, you can create and edit annotations in it. | |||
Open the extension’s popup and select that collection, then click “Connect” to try it out. If you had not authenticated to the server already, your browser may prompt you for your credentials. (In Chromium-based browsers, you may have to authenticate first on the website itself, as it blocks authentication to third parties.) | |||
Once you have connected to an annotation collection, you can create annotations by selecting text on any web page, ‘right’-clicking to get the context menu and choosing “Annotate Selection”. Write your notes and voilà, it should be stored/published on your annotation server. | |||
[Web Annotation Discovery Server]: https://code.treora.com/gerben/web-annotation-discovery-server | |||
## Develop | |||
Clone this repository, and install dependencies using `npm install` or equivalent. (Tested on Node 16 + npm 8.) | |||
You can build it with `npm run build`. The extension should appear as a zip file in a folder `web-ext-artifacts`. | |||
Running `npm run dev` should spin it up in a browser (choose which browser in `vite.config.ts`), with live reloading upon source file changes. | |||
### Code tour | |||
This browser extension is written in [TypeScript][], transpiled by [Vite][] (with [vite-webextension-plugin][]), using [Preact][] for the UI. It uses, and is co-developed with, [Apache Annotator][]. | |||
The extension’s local storage (abstracted by [Dexie][]) keeps a list of known annotations and of the annotation sources they come from. Each source that is an Annotation Container is considered a ‘feed’, and its annotations are downloaded again every couple of minutes (yes that could be made more efficient). | |||
For authentication to an annotation source (presumably required for creating annotations), this extension depends on the web browser’s capabilities; for simplicity (and because this would ideally be the browser’s job anyway), this extension has no clue about accounts, passwords, etc. This has been tested with HTTP Basic Auth, but possibly a server requiring e.g. client-side TLS certificates would work too. When the user “connects” to an annotation collection they want to store annotations in, the extension simply tests whether it can create a bogus annotation (which it directly deletes again). | |||
[TypeScript]: https://typescriptlang.org/ | |||
[Vite]: https://vitejs.dev/ | |||
[vite-webextension-plugin]: https://github.com/aklinker1/vite-plugin-web-extension/ | |||
[Preact]: https://preactjs.com/ | |||
[Dexie]: https://dexie.org/ | |||
[Apache Annotator]: https://annotator.apache.org/ | |||
## Licence | |||
This is free and unencumbered software released into the public domain. | |||
## Acknowledgements | |||
This project was [funded](https://nlnet.nl/project/WebAnnotation/) through the NGI0 Discovery Fund, a fund established by NLnet with financial support from the European Commission's Next Generation Internet programme. |
@@ -0,0 +1,38 @@ | |||
{ | |||
"name": "web-annotation-discovery-webextension", | |||
"version": "0.1.0", | |||
"scripts": { | |||
"build": "vite build && web-ext build -s dist --overwrite-dest", | |||
"dev": "vite dev" | |||
}, | |||
"author": "", | |||
"license": "Unlicense", | |||
"devDependencies": { | |||
"@types/deep-equal": "^1.0.1", | |||
"@types/firefox-webext-browser": "^94.0.1", | |||
"@types/lodash.debounce": "^4.0.7", | |||
"@types/uuid": "^8.3.4", | |||
"@types/webextension-polyfill": "^0.9.0", | |||
"@types/whatwg-mimetype": "^3.0.0", | |||
"typescript": "^4.7.4", | |||
"typescript-plugin-css-modules": "^3.4.0", | |||
"vite": "^2.9.14", | |||
"vite-plugin-web-extension": "^1.4.2", | |||
"web-ext": "^7.2.0" | |||
}, | |||
"dependencies": { | |||
"@apache-annotator/dom": "^0.3.0-dev.23", | |||
"@apache-annotator/selector": "^0.3.0-dev.23", | |||
"classnames": "^2.3.1", | |||
"deep-equal": "^2.0.5", | |||
"dexie": "^3.2.2", | |||
"lodash.debounce": "^4.0.8", | |||
"preact": "^10.10.0", | |||
"scroll-into-view-if-needed": "^2.2.29", | |||
"uuid": "^9.0.0", | |||
"web-annotation-utils": "git+https://code.treora.com/gerben/web-annotation-utils#latest", | |||
"webextension-polyfill": "^0.9.0", | |||
"webextension-rpc": "^0.3.0", | |||
"whatwg-mimetype": "^3.0.0" | |||
} | |||
} |
@@ -0,0 +1,14 @@ | |||
{ | |||
"appName": { | |||
"message": "Web Annotation Discovery", | |||
"description": "The name of the application" | |||
}, | |||
"appShortName": { | |||
"message": "WAD", | |||
"description": "The short_name (maximum of 12 characters recommended) is a short version of the app's name." | |||
}, | |||
"appDescription": { | |||
"message": "Browser extension to discover, display, and create Web Annotations", | |||
"description": "The description of the application" | |||
} | |||
} |
@@ -0,0 +1,41 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg | |||
width="10mm" | |||
height="10mm" | |||
viewBox="0 0 10 10" | |||
version="1.1" | |||
xmlns="http://www.w3.org/2000/svg" | |||
xmlns:svg="http://www.w3.org/2000/svg" | |||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | |||
xmlns:cc="http://creativecommons.org/ns#"> | |||
<rect | |||
style="display:inline;fill:#ffff00;stroke:#000000;stroke-width:1.39897" | |||
width="10.617116" | |||
height="8.6010437" | |||
x="2.9194891" | |||
y="0.69947922" | |||
ry="2.0371485" | |||
rx="2.0371485" | |||
clip-path="none" /> | |||
<path | |||
d="M 11.327955,8.1663486 H 10.41402 q -0.153787,0 -0.250453,-0.074451 -0.09667,-0.07883 -0.145,-0.1926961 L 9.5440248,6.6072619 H 6.9120694 L 6.4375265,7.8992017 q -0.035151,0.1007275 -0.1362114,0.1839371 -0.10106,0.08321 -0.2504531,0.08321 H 5.1281396 L 7.6282776,1.8336543 H 8.8322105 Z M 7.2152495,5.7751652 H 9.2408446 L 8.4675154,3.6686466 Q 8.4103946,3.528504 8.3488798,3.3401874 8.287365,3.1474912 8.2258501,2.9241389 8.1643353,3.1474912 8.1028205,3.3401874 8.0456995,3.5328834 7.9885787,3.6774055 Z" | |||
style="font-weight:bold;font-size:8.77336px;line-height:1.25;font-family:Lato;stroke-width:0.427093" /> | |||
<metadata> | |||
<rdf:RDF> | |||
<cc:License | |||
rdf:about="http://creativecommons.org/publicdomain/zero/1.0/"> | |||
<cc:permits | |||
rdf:resource="http://creativecommons.org/ns#Reproduction" /> | |||
<cc:permits | |||
rdf:resource="http://creativecommons.org/ns#Distribution" /> | |||
<cc:permits | |||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> | |||
</cc:License> | |||
<cc:Work | |||
rdf:about=""> | |||
<cc:license | |||
rdf:resource="http://creativecommons.org/publicdomain/zero/1.0/" /> | |||
</cc:Work> | |||
</rdf:RDF> | |||
</metadata> | |||
</svg> |
@@ -0,0 +1,5 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud-download" viewBox="0 0 16 16"> | |||
<path d="M4.406 1.342A5.53 5.53 0 0 1 8 0c2.69 0 4.923 2 5.166 4.579C14.758 4.804 16 6.137 16 7.773 16 9.569 14.502 11 12.687 11H10a.5.5 0 0 1 0-1h2.688C13.979 10 15 8.988 15 7.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 2.825 10.328 1 8 1a4.53 4.53 0 0 0-2.941 1.1c-.757.652-1.153 1.438-1.153 2.055v.448l-.445.049C2.064 4.805 1 5.952 1 7.318 1 8.785 2.23 10 3.781 10H6a.5.5 0 0 1 0 1H3.781C1.708 11 0 9.366 0 7.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383z"/> | |||
<path d="M7.646 15.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 14.293V5.5a.5.5 0 0 0-1 0v8.793l-2.146-2.147a.5.5 0 0 0-.708.708l3 3z"/> | |||
</svg> |
@@ -0,0 +1,5 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-question-circle" viewBox="0 0 16 16"> | |||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> | |||
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/> | |||
</svg> |
@@ -0,0 +1,6 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16"> | |||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/> | |||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/> | |||
</svg> |
@@ -0,0 +1,5 @@ | |||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rss" viewBox="0 0 16 16"> | |||
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/> | |||
<path d="M5.5 12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-3-8.5a1 1 0 0 1 1-1c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1-1-1zm0 4a1 1 0 0 1 1-1 6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1-1-1z"/> | |||
</svg> |
@@ -0,0 +1,35 @@ | |||
import { RpcClient } from 'webextension-rpc'; | |||
import { contentScriptRpcServer } from '../content_script'; | |||
async function handleAnnotateSelection( | |||
info: browser.contextMenus.OnClickData, | |||
tab: browser.tabs.Tab, | |||
) { | |||
await new RpcClient<typeof contentScriptRpcServer>({ tabId: tab.id }).func( | |||
'annotateSelection', | |||
)(); | |||
} | |||
function onContextMenuClick( | |||
info: browser.contextMenus.OnClickData, | |||
tab?: browser.tabs.Tab, | |||
) { | |||
if (!tab) return; | |||
switch (info.menuItemId) { | |||
case 'annotate-selection': | |||
handleAnnotateSelection(info, tab); | |||
break; | |||
} | |||
} | |||
async function init() { | |||
// Create context menu item | |||
browser.contextMenus.create({ | |||
id: 'annotate-selection', | |||
title: 'Annotate selection', | |||
contexts: ['selection'], | |||
}); | |||
browser.contextMenus.onClicked.addListener(onContextMenuClick); | |||
} | |||
init(); |
@@ -0,0 +1,60 @@ | |||
import { isAnnotationMimeType } from '../discovery'; | |||
import notify from '../util/notify'; | |||
import { AnnotationSource } from '../storage/AnnotationSource'; | |||
// When the user navigates to an annotation (collection) directly, suggest | |||
// whether they want to import the annotation(s). | |||
function onHeadersReceived({ | |||
responseHeaders, | |||
url, | |||
}: { | |||
responseHeaders?: browser.webRequest.HttpHeaders; | |||
url: string; | |||
}) { | |||
const isAnnotation = responseHeaders?.some( | |||
(header) => | |||
header.name.toLowerCase() === 'content-type' && | |||
header.value && | |||
isAnnotationMimeType(header.value), | |||
); | |||
if (isAnnotation) { | |||
notify({ | |||
title: 'Import Web Annotation(s)', | |||
iconUrl: '/assets/icon/icon.svg', | |||
message: | |||
'You opened a Web Annotation (Collection). Click here to import it to your annotation library.', | |||
onClicked() { | |||
// Note we do not have the response yet at this point. For simplicity, just | |||
// add it as an (inactive) source, then it will be fetched again. | |||
AnnotationSource.addSource( | |||
{ | |||
type: 'container', | |||
url, | |||
title: 'Manually imported annotation', | |||
}, | |||
false, | |||
); | |||
}, | |||
}); | |||
} | |||
} | |||
browser.webRequest.onHeadersReceived.addListener( | |||
onHeadersReceived, | |||
{ | |||
urls: ['<all_urls>'], | |||
types: ['main_frame'], | |||
}, | |||
['responseHeaders'], | |||
); | |||
// As files loaded through file://… URLs do not have headers, we run a separate check for these. | |||
async function onVisitFileUrl({ url, tabId }: { url: string; tabId: number }) { | |||
// TODO (likely to be quite a hassle, if possible at all..) | |||
} | |||
browser.webNavigation.onCommitted.addListener(onVisitFileUrl, { | |||
url: [{ schemes: ['file'] }], | |||
}); |
@@ -0,0 +1,84 @@ | |||
import '../webextension-polyfill'; | |||
import { RpcServer } from 'webextension-rpc'; | |||
import type { WebAnnotation } from 'web-annotation-utils'; | |||
import { Annotation, IAnnotation, IAnnotationWithSource } from '../storage/Annotation'; | |||
import { | |||
AnnotationSource, | |||
AnnotationSourceDescriptor, | |||
} from '../storage/AnnotationSource'; | |||
import './detect-annotation'; | |||
import './context-menu'; | |||
const backgroundRpcServer = new RpcServer({ | |||
async getAnnotationsForTargetUrls(urls: string[]): Promise<IAnnotationWithSource[]> { | |||
const annotations = await Annotation.getAnnotationsForUrls(urls); | |||
const annotationsWithSources = await Promise.all(annotations.map(async (a) => a.expand())); | |||
return annotationsWithSources | |||
}, | |||
refreshAnnotationSource: async ( | |||
source: AnnotationSourceDescriptor, | |||
force = false, | |||
) => { | |||
const sourceObj = await AnnotationSource.getByUrl(source.url); | |||
await sourceObj.refresh(force); | |||
}, | |||
refreshAnnotationSources, | |||
addAnnotationSource: async (source: AnnotationSourceDescriptor) => | |||
AnnotationSource.addSource(source), | |||
isSourceSubscribed: async (source: AnnotationSourceDescriptor) => | |||
AnnotationSource.exists(source), | |||
removeSource: async (sourceDescriptor: AnnotationSourceDescriptor) => { | |||
const sourceObj = await AnnotationSource.getByUrl(sourceDescriptor.url); | |||
await sourceObj.delete(); | |||
}, | |||
async createAnnotation(annotationStub: Partial<WebAnnotation>) { | |||
const annotation = await AnnotationSource.createAnnotation(annotationStub); | |||
return await annotation.expand(); | |||
}, | |||
async updateAnnotation(id: IAnnotation['_id'], webAnnotation: WebAnnotation) { | |||
const annotation = await Annotation.get(id); | |||
await annotation.update(webAnnotation); | |||
}, | |||
async deleteAnnotation(id: IAnnotation['_id']) { | |||
const annotation = await Annotation.get(id); | |||
await annotation.delete(); | |||
}, | |||
}); | |||
export type { backgroundRpcServer }; | |||
async function refreshAnnotationSources({ | |||
forceAll = false, | |||
}: { forceAll?: boolean } = {}) { | |||
const sourcesToUpdate = await (forceAll | |||
? AnnotationSource.getActiveSources() | |||
: AnnotationSource.getSourcesNeedingUpdate()); | |||
console.log(`Will update ${sourcesToUpdate.length} sources.`); | |||
await Promise.all( | |||
sourcesToUpdate.map(async (source) => { | |||
try { | |||
await source.refresh(); | |||
} catch (error) { | |||
console.log( | |||
`Failed to refresh source “${source.data.title}” <${source.data.url}>`, | |||
error, | |||
); | |||
} | |||
}), | |||
); | |||
} | |||
main(); | |||
async function main() { | |||
await refreshAnnotationSources(); | |||
await browser.alarms.clearAll(); | |||
browser.alarms.create('annotationPeriodicRefresh', { periodInMinutes: 1 }); | |||
if (!browser.alarms.onAlarm.hasListener(handleAlarm)) { | |||
browser.alarms.onAlarm.addListener(handleAlarm); | |||
} | |||
} | |||
async function handleAlarm(alarm: browser.alarms.Alarm) { | |||
if (alarm.name !== 'annotationPeriodicRefresh') return; | |||
await refreshAnnotationSources(); | |||
} |
@@ -0,0 +1,42 @@ | |||
html body { | |||
.select, | |||
.button { | |||
max-width: 100%; | |||
box-sizing: border-box; | |||
text-decoration: none; | |||
color: unset; | |||
// background-color: #eee; | |||
// border: 1px solid lightgrey; | |||
// box-shadow: 1px 1px 1px grey; | |||
// background-color: rgb(255, 235, 123); | |||
background-color: lightyellow; | |||
border: 1px solid rgb(179, 179, 0); | |||
box-shadow: 1px 1px 1px grey; | |||
border-radius: 4px; | |||
margin: 4px; | |||
padding: 6px; | |||
} | |||
.button { | |||
cursor: pointer; | |||
&.loading, | |||
&:active { | |||
transform: translate(1px, 1px); | |||
box-shadow: 0.5px 0.5px 0.5px grey; | |||
} | |||
&.loading, | |||
&:disabled { | |||
color: grey; | |||
} | |||
&.success { | |||
background-color: lightgreen; | |||
border-color: green; | |||
} | |||
&.success::after { | |||
content: ' ✔️'; | |||
} | |||
} | |||
} |
@@ -0,0 +1,53 @@ | |||
@import '../common-classes.scss'; | |||
.sourceList { | |||
padding: unset; | |||
list-style: none; | |||
li.source { | |||
margin: 2em 0; | |||
.infoAndButtons { | |||
display: flex; | |||
align-content: space-between; | |||
} | |||
.info { | |||
flex-grow: 1; | |||
} | |||
.buttons { | |||
flex-shrink: 0; | |||
display: flex; | |||
flex-direction: column; | |||
} | |||
} | |||
details.showAnnotations { | |||
& > summary { | |||
cursor: pointer; | |||
} | |||
margin-top: 1em; | |||
margin-bottom: 4em; | |||
} | |||
.title { | |||
} | |||
.url { | |||
max-width: 20em; | |||
overflow-x: hidden; | |||
text-overflow: ellipsis; | |||
font-size: smaller; | |||
} | |||
.sourceType { | |||
margin-top: 1em; | |||
font-size: smaller; | |||
} | |||
.lastUpdate { | |||
font-size: smaller; | |||
margin-top: 1em; | |||
font-style: italic; | |||
} | |||
} |
@@ -0,0 +1,173 @@ | |||
import { Component, h, Fragment, createRef } from 'preact'; | |||
import { RpcClient } from 'webextension-rpc'; | |||
import type { backgroundRpcServer } from '../background'; | |||
import { AnnotationSource } from '../storage/AnnotationSource'; | |||
import classes from './AnnotationSourcesList.module.scss'; | |||
import cls from 'classnames'; | |||
import niceTime from '../util/niceTime'; | |||
import { Annotation } from '../storage/Annotation'; | |||
import { AnnotationsList } from './AnnotationsList'; | |||
import infoIcon from '../assets/icons/info.svg'; | |||
import rssIcon from '../assets/icons/rss.svg'; | |||
const backgroundRpc = new RpcClient<typeof backgroundRpcServer>(); | |||
// Run refresh in the background: should continue if the page/popup is closed. | |||
const refreshAnnotationSource = backgroundRpc.func('refreshAnnotationSource'); | |||
const refreshAnnotationSources = backgroundRpc.func('refreshAnnotationSources'); | |||
interface AnnotationSourcesListProps { | |||
withAnnotations?: boolean; | |||
} | |||
interface AnnotationSourcesListState { | |||
sources?: AnnotationSource[]; | |||
totalAnnotationCount?: number; | |||
} | |||
export class AnnotationSourcesList extends Component< | |||
AnnotationSourcesListProps, | |||
AnnotationSourcesListState | |||
> { | |||
state: AnnotationSourcesListState = {}; | |||
refreshButton = createRef<HTMLButtonElement>(); | |||
async componentDidMount() { | |||
await this.loadData(); | |||
} | |||
async loadData() { | |||
this.setState({ | |||
sources: await AnnotationSource.getAll(), | |||
// sources: await AnnotationSource.getActiveSources(), | |||
totalAnnotationCount: await Annotation.count(), | |||
}); | |||
} | |||
refreshAll = async () => { | |||
const button = this.refreshButton.current!; | |||
try { | |||
button.classList.add(classes.loading); | |||
button.disabled = true; | |||
await refreshAnnotationSources({ forceAll: true }); | |||
await this.loadData(); | |||
} catch (error) { | |||
alert(`Refreshing failed: ${error}`); | |||
throw error; | |||
} finally { | |||
button.disabled = false; | |||
button.classList.remove(classes.loading); | |||
} | |||
}; | |||
refreshSource = async (source: AnnotationSource) => { | |||
try { | |||
await refreshAnnotationSource(source.data, true); | |||
} catch (error) { | |||
alert(`Refreshing failed: ${error}`); | |||
} | |||
await this.loadData(); | |||
}; | |||
removeSource = async (source: AnnotationSource) => { | |||
await source.delete(); | |||
await this.loadData(); | |||
}; | |||
render( | |||
{ withAnnotations }: AnnotationSourcesListProps, | |||
{ sources }: AnnotationSourcesListState, | |||
) { | |||
const sourceList = sources?.map((source) => ( | |||
<li | |||
key={source.data._id} | |||
class={cls(classes.source, { [classes.active]: source.data.active })} | |||
> | |||
<div class={classes.infoAndButtons}> | |||
<div class={classes.info}> | |||
<div class={classes.title}> | |||
{source.data.active && ( | |||
<> | |||
<img src={rssIcon} />{' '} | |||
</> | |||
)} | |||
“{source.data.title}” | |||
</div> | |||
<div class={classes.url}> | |||
{source.data.active ? 'Feed: ' : 'Source: '} | |||
<a href={source.data.url} target="_blank"> | |||
{source.data.url} | |||
</a> | |||
</div>{' '} | |||
<div class={classes.lastUpdate}> | |||
{source.data.active ? 'Last update: ' : 'Imported: '} | |||
{source.data.lastUpdate ? niceTime(source.data.lastUpdate) : '?'} | |||
{source.data.active || ( | |||
<small> (this source is not refreshed automatically)</small> | |||
)} | |||
</div> | |||
</div> | |||
<div class={classes.buttons}> | |||
<button | |||
class={classes.button} | |||
onClick={(e) => this.removeSource(source)} | |||
> | |||
❌ Remove | |||
</button> | |||
<button | |||
class={classes.button} | |||
onClick={(e) => this.refreshSource(source)} | |||
title={ | |||
source.data.type === 'embeddedJsonld' | |||
? `To refresh annotations embedded in a page, first open that page again.` | |||
: undefined | |||
} | |||
> | |||
🗘 Refresh | |||
{source.data.type === 'embeddedJsonld' && ( | |||
<> | |||
{' '} | |||
<img src={infoIcon} /> | |||
</> | |||
)} | |||
</button> | |||
</div> | |||
</div> | |||
{withAnnotations && ( | |||
<details class={classes.showAnnotations} open> | |||
<summary>Stored annotations from this source</summary> | |||
<div style="margin-left: 2em;"> | |||
<AnnotationsList source={source.data} /> | |||
</div> | |||
</details> | |||
)} | |||
</li> | |||
)); | |||
return ( | |||
<div> | |||
{sourceList ? ( | |||
<ul class={classes.sourceList}>{sourceList}</ul> | |||
) : ( | |||
<p> | |||
<i>Loading list of sources…</i> | |||
</p> | |||
)} | |||
<div style="display: flex; align-items: center;"> | |||
<div style="flex-grow: 1;"> | |||
Total number of annotations:{' '} | |||
{this.state.totalAnnotationCount ?? <i>'counting…'</i>} | |||
</div> | |||
<button | |||
class={classes.button} | |||
ref={this.refreshButton} | |||
onClick={this.refreshAll} | |||
> | |||
🗘 Refresh all sources | |||
</button> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,22 @@ | |||
.annotationList { | |||
padding: unset; | |||
list-style: none; | |||
.annotationWrapper { | |||
margin: 1em 0 3em 0; | |||
} | |||
.viewAsJson { | |||
font-size: smaller; | |||
} | |||
.annotationBody { | |||
max-width: 300px; | |||
border: #999 1px solid; | |||
border-radius: 10px; | |||
box-shadow: 10px 10px 10px #9996; | |||
margin: 10px 0; | |||
padding: 10px; | |||
background-color: lightyellow; | |||
} | |||
} |
@@ -0,0 +1,93 @@ | |||
import { Component, h, Fragment } from 'preact'; | |||
import classes from './AnnotationsList.module.scss'; | |||
import { Annotation } from '../storage/Annotation'; | |||
import { getTargetUrls } from 'web-annotation-utils'; | |||
import { IAnnotationSource } from '../storage/AnnotationSource'; | |||
import { AnnotationBody } from '../content_script/AnnotationBody'; | |||
interface AnnotationsListProps { | |||
source?: IAnnotationSource; | |||
withTotal?: boolean; | |||
} | |||
interface AnnotationsListState { | |||
annotations?: Annotation[]; | |||
totalAnnotationCount?: number; | |||
} | |||
export class AnnotationsList extends Component< | |||
AnnotationsListProps, | |||
AnnotationsListState | |||
> { | |||
state: AnnotationsListState = {}; | |||
async componentDidMount() { | |||
await this.loadData(); | |||
} | |||
async componentDidUpdate(previousProps: Readonly<AnnotationsListProps>) { | |||
if ( | |||
previousProps.source?.lastModified !== this.props.source?.lastModified | |||
) { | |||
this.loadData(); | |||
} | |||
} | |||
async loadData() { | |||
let annotations; | |||
if (this.props.source) { | |||
if (this.props.source._id === undefined) | |||
throw new Error(`Got source without an _id`); | |||
annotations = await Annotation.getAnnotationsFromSource( | |||
this.props.source._id, | |||
); | |||
} else { | |||
annotations = await Annotation.getAll(); | |||
} | |||
this.setState({ annotations }); | |||
} | |||
render( | |||
{ withTotal }: AnnotationsListProps, | |||
{ annotations }: AnnotationsListState, | |||
) { | |||
const annotationList = annotations?.map((annotation) => { | |||
const webAnnotation = annotation.data.annotation; | |||
return ( | |||
<li class={classes.annotationWrapper} key={annotation.data._id}> | |||
<div class={classes.annotationBody}> | |||
<AnnotationBody | |||
body={webAnnotation.body} | |||
bodyValue={webAnnotation.bodyValue} | |||
/> | |||
</div> | |||
<div> | |||
On:{' '} | |||
{getTargetUrls(webAnnotation.target).map((url) => ( | |||
<><a href={url}>{url}</a>{' '}</> | |||
))} | |||
</div> | |||
{' '} | |||
{/* <div class={classes.viewAsJson}> | |||
<a target="_blank" href={`data:application/json,${encodeURIComponent(JSON.stringify(annotation.data.annotation, null, 2))}`}>View annotation JSON</a> | |||
</div> */} | |||
</li> | |||
); | |||
}); | |||
return ( | |||
<> | |||
{annotationList ? ( | |||
<ul class={classes.annotationList}>{annotationList}</ul> | |||
) : ( | |||
<p> | |||
<i>Loading…</i> | |||
</p> | |||
)} | |||
{withTotal && ( | |||
<p>Total: {this.state.annotations?.length ?? <i>'counting…'</i>}</p> | |||
)} | |||
</> | |||
); | |||
} | |||
} |
@@ -0,0 +1,25 @@ | |||
html body { /* Increase specifity above that of our style reset */ | |||
.annotationBodyIframe { | |||
width: 100%; | |||
height: auto; | |||
border: none; | |||
background-color: transparent; | |||
} | |||
.annotationBodyAudio { | |||
width: 100%; | |||
border-radius: 20px; | |||
} | |||
.annotationBodyVideo { | |||
width: 100%; | |||
height: auto; | |||
} | |||
.annotationBodyText { | |||
padding: 5px 10px; | |||
min-height: 1em; | |||
white-space: pre-wrap; | |||
&[contenteditable=true]:hover { | |||
background: #fffc; | |||
} | |||
} | |||
} |
@@ -0,0 +1,164 @@ | |||
import { h, Component, ComponentChild, createRef } from 'preact'; | |||
import { asArray } from 'web-annotation-utils'; | |||
import type { BodyChoice, TextualBody, WebAnnotation } from 'web-annotation-utils'; | |||
import classes from './AnnotationBody.module.scss'; | |||
interface AnnotationBodyProps { | |||
body: WebAnnotation['body']; | |||
bodyValue: WebAnnotation['bodyValue']; | |||
editable?: boolean; | |||
onChange?: (newBody: WebAnnotation['body']) => void; | |||
onBlur?: () => void; | |||
inFocus?: boolean; | |||
} | |||
interface AnnotationBodyState {} | |||
export class AnnotationBody extends Component< | |||
AnnotationBodyProps, | |||
AnnotationBodyState | |||
> { | |||
editorElement = createRef<HTMLDivElement>(); | |||
componentDidMount() { | |||
if (this.props.inFocus) { | |||
this.editorElement.current?.focus({ preventScroll: true }); | |||
} | |||
} | |||
componentDidUpdate(previousProps: Readonly<AnnotationBodyProps>) { | |||
if (this.props.inFocus && !previousProps.inFocus) { | |||
this.editorElement.current?.focus(); | |||
} | |||
} | |||
render({ body, bodyValue, editable }: AnnotationBodyProps) { | |||
// An annotation either contains a `bodyValue` (simply a string), or an actual `body`. | |||
if (bodyValue !== undefined) return this.renderBodyValue(bodyValue); | |||
const result = this.renderBody(body); | |||
if (result === null && editable) { | |||
// For an empty but editable body, render an empty text field. | |||
return this.renderBodyValue(''); | |||
} else { | |||
return result; | |||
} | |||
} | |||
renderBodyValue(bodyValue: string) { | |||
// A bodyValue is defined as equivalent to a TextualBody containing this value. | |||
return this.renderTextualBody({ | |||
type: 'TextualBody', | |||
value: bodyValue, | |||
format: 'text/plain', | |||
}); | |||
} | |||
renderTextualBody(body: TextualBody) { | |||
// TODO use other available information: textDirection, format, …? | |||
return ( | |||
<div | |||
ref={this.editorElement} | |||
class={classes.annotationBodyText} | |||
contentEditable={this.props.editable} | |||
spellcheck={false} | |||
onInput={(e) => | |||
this.props.onChange?.({ | |||
...body, | |||
value: (e.target as HTMLParagraphElement).textContent!, | |||
}) | |||
} | |||
onBlur={() => this.props.onBlur?.()} | |||
> | |||
{body.value} | |||
</div> | |||
); | |||
} | |||
renderBody(body: WebAnnotation['body']): ComponentChild { | |||
if (!body) { | |||
return null; | |||
} | |||
// A body can take many forms. Handle each as well as we can. | |||
// Firstly, it could be a string, identifying the body resource. | |||
if (typeof body === 'string') { | |||
// We assume the body is the URL of the body content. | |||
// TODO Handle the case where this string instead refers to a JSON-LD node (e.g. a SpecificResource or Choice). | |||
return this.renderIframe(body); | |||
} | |||
// There can be multiple bodies (according to the spec, each body is equally applicable to each target). | |||
if (Array.isArray(body)) { | |||
// We simply concatenate the bodies. Perhaps not the clearest/prettiest, but simple. | |||
return body.map((actualBody) => this.renderBody(actualBody)); | |||
} | |||
// TextualBody, a body consisting of a simple text value. | |||
if ('type' in body && body.type === 'TextualBody') { | |||
return this.renderTextualBody(body as TextualBody); | |||
} | |||
if ('type' in body && body.type === 'Choice') { | |||
const bodyOptions = (body as BodyChoice).items; | |||
if (bodyOptions.length === 0) return null; | |||
// The default option is listed first; take that. | |||
return this.renderBody(bodyOptions[0]); | |||
} | |||
if ('source' in body) { | |||
// The body is a Specific Resource. | |||
// TODO Try render exactly that part of the resource indicated by body.selector. | |||
return this.renderIframe(body.source); | |||
} | |||
// The body is an External Web Resource. Depending on its type, render an appropriate element. | |||
if ( | |||
asArray(body.format).every((item) => item.startsWith('image/')) || | |||
asArray(body.type).every((item) => item === 'Image') | |||
) { | |||
return this.renderImage(body.id); | |||
} | |||
if ( | |||
asArray(body.format).every((item) => item.startsWith('audio/')) || | |||
asArray(body.type).every((item) => item === 'Sound') | |||
) { | |||
return this.renderAudio(body.id); | |||
} | |||
if ( | |||
asArray(body.format).every((item) => item.startsWith('video/')) || | |||
asArray(body.type).every((item) => item === 'Video') | |||
) { | |||
return this.renderAudio(body.id); | |||
} | |||
return this.renderIframe(body.id); | |||
} | |||
renderImage(bodyUrl: string) { | |||
return <img src={bodyUrl}></img>; | |||
} | |||
renderAudio(bodyUrl: string) { | |||
return ( | |||
<audio class={classes.annotationBodyAudio} controls src={bodyUrl}></audio> | |||
); | |||
} | |||
renderVideo(bodyUrl: string) { | |||
return <video class={classes.annotationBodyVideo} src={bodyUrl}></video>; | |||
} | |||
renderIframe(bodyUrl: string) { | |||
return ( | |||
<iframe | |||
class={classes.annotationBodyIframe} | |||
sandbox="" | |||
src={bodyUrl} | |||
></iframe> | |||
); | |||
} | |||
} |
@@ -0,0 +1,72 @@ | |||
import { Component } from 'preact'; | |||
import type { IAnnotationWithSource } from '../storage/Annotation'; | |||
import { RpcClient } from 'webextension-rpc'; | |||
import { describeTextQuote } from '@apache-annotator/dom'; | |||
import type { WebAnnotation } from 'web-annotation-utils'; | |||
import type { backgroundRpcServer } from '../background'; | |||
const backgroundRpc = new RpcClient<typeof backgroundRpcServer>(); | |||
const createAnnotation = backgroundRpc.func('createAnnotation'); | |||
export async function annotateSelection() { | |||
// TODO Use something else than document, so the page’s own scripts cannot observe the reader’s annotation activity. | |||
document.dispatchEvent(new CustomEvent('createAnnotation')); | |||
} | |||
function deselect() { | |||
const selection = window.getSelection(); | |||
if (selection) selection.removeAllRanges(); | |||
} | |||
interface AnnotationCreationHelperProps { | |||
onAnnotationCreated: (annotation: IAnnotationWithSource) => void; | |||
} | |||
interface AnnotationCreationHelperState {} | |||
export class AnnotationCreationHelper extends Component< | |||
AnnotationCreationHelperProps, | |||
AnnotationCreationHelperState | |||
> { | |||
state: AnnotationCreationHelperState = {}; | |||
constructor() { | |||
super(); | |||
this.onCreateAnnotation = this.onCreateAnnotation.bind(this); | |||
} | |||
override componentDidMount(): void { | |||
document.addEventListener('createAnnotation', this.onCreateAnnotation); | |||
} | |||
override componentWillUnmount(): void { | |||
document.removeEventListener('createAnnotation', this.onCreateAnnotation); | |||
} | |||
async onCreateAnnotation() { | |||
const target: WebAnnotation['target'] = { | |||
source: document.URL.split('#')[0], | |||
}; | |||
const selection = window.getSelection(); | |||
if (selection && !selection.isCollapsed) { | |||
const range = selection.getRangeAt(0); | |||
const selector = await describeTextQuote(range); | |||
target.selector = selector; | |||
deselect(); | |||
} | |||
let createdAnnotation; | |||
const annotationStub = { target } | |||
try { | |||
createdAnnotation = await createAnnotation(annotationStub); | |||
} catch (error: any) { | |||
const newError = new Error(`Error creating the annotation: ${error.message}`) | |||
alert(newError.message); | |||
throw newError; | |||
} | |||
this.props.onAnnotationCreated(createdAnnotation); | |||
} | |||
render() { | |||
return undefined; | |||
} | |||
} |
@@ -0,0 +1,232 @@ | |||
import { Component, ComponentChild, RenderableProps, h } from 'preact'; | |||
import deepEqual from 'deep-equal'; | |||
import scrollIntoView from 'scroll-into-view-if-needed'; | |||
import classes from './MarginalAnnotations.module.scss'; | |||
import cls from 'classnames'; | |||
import { asArray, targetsUrl } from 'web-annotation-utils'; | |||
import { highlightText } from '@apache-annotator/dom'; | |||
import { | |||
MarginalAnnotationCard, | |||
MarginalAnnotationCardProps, | |||
} from './MarginalAnnotationCard'; | |||
import { findTargetsInDocument } from '../util/dom-selectors' | |||
import type { IAnnotationWithSource } from '../storage/Annotation'; | |||
interface AnnotationTargetHighlightProps extends MarginalAnnotationCardProps { | |||
appContainer: Node; | |||
annotation: IAnnotationWithSource; | |||
inFocus?: boolean; | |||
} | |||
interface AnnotationTargetHighlightState { | |||
highlightHovered: boolean; | |||
highlightClicked: boolean; | |||
anchorPosition?: number; | |||
} | |||
// This component renders the annotation card, but as a side | |||
// effect highlights the annotation’s target in the page. It adds classes and | |||
// event listeners to the wrapped annotation, to light up matching bodies and | |||
// targets on clicking&hovering. | |||
export class AnnotationTargetHighlight extends Component< | |||
AnnotationTargetHighlightProps, | |||
AnnotationTargetHighlightState | |||
> { | |||
state: AnnotationTargetHighlightState = { | |||
highlightHovered: false, | |||
highlightClicked: false, | |||
}; | |||
highlightCleanupFunctions: (() => void)[] = []; | |||
highlightMarkElements: HTMLElement[] = []; | |||
constructor() { | |||
super(); | |||
this.handleMouseClickOnCard = this.handleMouseClickOnCard.bind(this); | |||
this.handleHighlightMouseEnter = this.handleHighlightMouseEnter.bind(this); | |||
this.handleHighlightMouseLeave = this.handleHighlightMouseLeave.bind(this); | |||
this.handleKeyUp = this.handleKeyUp.bind(this); | |||
} | |||
componentDidMount() { | |||
this.highlightTargets(); | |||
// if (this.props.inFocus) { | |||
// this.setState({ highlightClicked: true }); | |||
// } | |||
} | |||
componentDidUpdate( | |||
previousProps: Readonly<AnnotationTargetHighlightProps>, | |||
previousState: Readonly<AnnotationTargetHighlightState>, | |||
) { | |||
if (this.state.highlightClicked !== previousState.highlightClicked) { | |||
this.highlightMarkElements.forEach((markElement) => | |||
this.state.highlightClicked | |||
? markElement.classList.add(classes.clicked) | |||
: markElement.classList.remove(classes.clicked), | |||
); | |||
} | |||
if (this.state.highlightHovered !== previousState.highlightHovered) { | |||
this.highlightMarkElements.forEach((markElement) => | |||
this.state.highlightHovered | |||
? markElement.classList.add(classes.hovered) | |||
: markElement.classList.remove(classes.hovered), | |||
); | |||
} | |||
if ( | |||
!deepEqual( | |||
this.props.annotation.annotation.target, | |||
previousProps.annotation.annotation.target, | |||
) | |||
) { | |||
this.removeHighlights(); | |||
this.highlightTargets(); | |||
} | |||
if (this.props.inFocus && !previousProps.inFocus) { | |||
this.setState({ highlightClicked: true }); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.removeHighlights(); | |||
} | |||
async highlightTargets() { | |||
const targets = asArray(this.props.annotation.annotation.target); | |||
// Annotations may have multiple targets; ignore those not on this page. | |||
const targetsForThisPage = targets.filter((target) => | |||
targetsUrl(target, document.URL), | |||
); | |||
// Try find the annotation targets within the document (if the target has a Selector). | |||
const domTargets = await findTargetsInDocument(targetsForThisPage); | |||
// Highlight the found targets. | |||
this.highlightCleanupFunctions = domTargets.flatMap((domTarget) => { | |||
// Ignore any matches inside our own annotations etc. | |||
if ( | |||
(domTarget instanceof Range && | |||
domTarget.intersectsNode(this.props.appContainer)) || | |||
(domTarget instanceof Node && | |||
domTarget.compareDocumentPosition(this.props.appContainer) & | |||
Node.DOCUMENT_POSITION_CONTAINS) | |||
) { | |||
return []; | |||
} | |||
// Highlight the text contained in the Range using <mark> elements. | |||
const highlightCleanupFunction = highlightText(domTarget, 'mark', { | |||
class: classes.highlight, | |||
'data-annotation-highlight-temp': 'true', | |||
}); | |||
// TODO tweak highlightText upstream to not need this silly workaround. | |||
const markElements = [ | |||
...document.querySelectorAll('[data-annotation-highlight-temp]'), | |||
] as HTMLElement[]; | |||
// React to mouse interactions with the highlight. | |||
markElements.forEach((markElement) => { | |||
markElement.removeAttribute('data-annotation-highlight-temp'); | |||
markElement.addEventListener('click', () => this.handleMouseClick()); | |||
markElement.addEventListener('mouseenter', () => | |||
this.handleHighlightMouseEnter(), | |||
); | |||
markElement.addEventListener('mouseleave', () => | |||
this.handleHighlightMouseLeave(), | |||
); | |||
}); | |||
this.highlightMarkElements.push(...markElements); | |||
return [highlightCleanupFunction]; | |||
}); | |||
if (this.highlightMarkElements.length > 0) { | |||
const top = this.highlightMarkElements[0].getBoundingClientRect().top + window.scrollY; | |||
this.setState({ | |||
anchorPosition: top, | |||
}); | |||
} | |||
} | |||
removeHighlights() { | |||
this.highlightCleanupFunctions.forEach((removeHighlight) => | |||
removeHighlight(), | |||
); | |||
this.highlightCleanupFunctions = []; | |||
this.highlightMarkElements = []; | |||
this.setState({ | |||
highlightClicked: false, | |||
highlightHovered: false, | |||
}); | |||
} | |||
handleMouseClick() { | |||
this.setState((currentState) => ({ | |||
highlightClicked: !currentState.highlightClicked, | |||
})); | |||
} | |||
handleMouseClickOnCard() { | |||
if (!this.state.highlightClicked && this.highlightMarkElements.length > 0) { | |||
const firstMarkElement = this.highlightMarkElements[0]; | |||
if (firstMarkElement.getBoundingClientRect().top) | |||
scrollIntoView(firstMarkElement, { behavior: 'smooth', block: 'center', scrollMode: 'if-needed' }); | |||
} | |||
this.setState({ | |||
highlightClicked: false, | |||
}); | |||
} | |||
handleHighlightMouseEnter() { | |||
this.setState({ highlightHovered: true }); | |||
} | |||
handleHighlightMouseLeave() { | |||
this.setState({ highlightHovered: false }); | |||
} | |||
handleKeyUp(event: KeyboardEvent) { | |||
if (event.key === 'Escape') { | |||
this.setState({ highlightClicked: false }); | |||
// TODO move focus out of annotation to hide it. | |||
} | |||
} | |||
render( | |||
{ | |||
annotation, | |||
inFocus, | |||
...otherProps | |||
}: RenderableProps<AnnotationTargetHighlightProps>, | |||
{ | |||
highlightHovered, | |||
highlightClicked, | |||
anchorPosition, | |||
}: Readonly<AnnotationTargetHighlightState>, | |||
): ComponentChild { | |||
const extraClasses = cls({ | |||
[classes.highlightClicked]: highlightClicked, | |||
[classes.highlightHovered]: highlightHovered, | |||
}); | |||
// Pass the extra class to the wrapped annotation. | |||
return ( | |||
<MarginalAnnotationCard | |||
{...otherProps} | |||
annotation={annotation} | |||
onclick={this.handleMouseClickOnCard} | |||
onmouseenter={this.handleHighlightMouseEnter} | |||
onmouseleave={this.handleHighlightMouseLeave} | |||
onkeyup={this.handleKeyUp} | |||
anchorPosition={anchorPosition} | |||
extraClasses={extraClasses} | |||
inFocus={inFocus} | |||
/> | |||
); | |||
} | |||
} |
@@ -0,0 +1,204 @@ | |||
import { h, Fragment, Component } from 'preact'; | |||
import type { AnnotationSourceDescriptor } from '../storage/AnnotationSource'; | |||
import { RpcClient } from 'webextension-rpc'; | |||
import type { backgroundRpcServer } from '../background'; | |||
import { MarginalAnnotations } from './MarginalAnnotations'; | |||
import { ToolbarButtons } from './ToolbarButtons'; | |||
import { | |||
discoverAnnotationsEmbeddedAsJSONLD, | |||
discoverAnnotationSources, | |||
} from './discovery'; | |||
import { targetsUrl } from 'web-annotation-utils'; | |||
import { unique } from '../util/unique'; | |||
import type { IAnnotationWithSource } from '../storage/Annotation'; | |||
import { AnnotationCreationHelper } from './AnnotationCreationHelper'; | |||
const backgroundRpc = new RpcClient<typeof backgroundRpcServer>(); | |||
const getAnnotationsForTargetUrls = backgroundRpc.func( | |||
'getAnnotationsForTargetUrls', | |||
); | |||
const isSourceSubscribed = backgroundRpc.func('isSourceSubscribed'); | |||
const updateAnnotation = backgroundRpc.func('updateAnnotation'); | |||
const deleteAnnotation = backgroundRpc.func('deleteAnnotation'); | |||
interface AppProps { | |||
appContainer: Node; | |||
} | |||
interface AppState { | |||
storedAnnotations: IAnnotationWithSource[]; | |||
embeddedAnnotations: IAnnotationWithSource[]; | |||
annotationInFocus?: IAnnotationWithSource; | |||
discoveredLinkedSources: (AnnotationSourceDescriptor & { | |||
subscribed: boolean; | |||
})[]; | |||
discoveredEmbeddedSources: (AnnotationSourceDescriptor & { | |||
subscribed: boolean; | |||
})[]; | |||
} | |||
export class App extends Component<AppProps, AppState> { | |||
state: AppState = { | |||
storedAnnotations: [], | |||
embeddedAnnotations: [], | |||
discoveredLinkedSources: [], | |||
discoveredEmbeddedSources: [], | |||
}; | |||
async componentDidMount() { | |||
try { | |||
await Promise.all([ | |||
this.loadStoredAnnotations(), | |||
this.discoverEmbeddedAnnotations(), | |||
this.discoverLinkedAnnotationSources(), | |||
]); | |||
} catch (error) { | |||
console.log(error); | |||
} | |||
} | |||
async loadStoredAnnotations() { | |||
// Find annotations in our storage that target the current page. | |||
const urls = [document.URL]; | |||
const canonicalLink = document.querySelector( | |||
'link[rel~="canonical"]', | |||
) as HTMLLinkElement | null; | |||
if (canonicalLink) urls.push(canonicalLink.href); | |||
const storedAnnotations = await getAnnotationsForTargetUrls(urls); | |||
console.log( | |||
`We got these annotations for <${document.URL}>:`, | |||
storedAnnotations, | |||
); | |||
this.setState({ | |||
storedAnnotations, | |||
}); | |||
} | |||
async discoverEmbeddedAnnotations() { | |||
// Find annotations embedded inside the page. | |||
const embeddedAnnotations = discoverAnnotationsEmbeddedAsJSONLD(); | |||
const embeddedAnnotationsTargetingThisPage = embeddedAnnotations.filter( | |||
(annotation) => targetsUrl(annotation.target, document.URL), | |||
); | |||
console.log( | |||
`Found ${embeddedAnnotations.length} embedded annotations in the page, of which ${embeddedAnnotationsTargetingThisPage.length} target this page itself.`, | |||
); | |||
this.setState({ | |||
embeddedAnnotations: embeddedAnnotationsTargetingThisPage.map( | |||
(annotation) => ({ | |||
_id: 0, | |||
source: { | |||
_id: -1, | |||
active: false, | |||
type: 'embeddedJsonld', | |||
url: document.URL, | |||
}, | |||
annotation, | |||
}), | |||
), | |||
}); | |||
// A page with embedded annotations targeting *other* pages is considered an annotation source. | |||
if ( | |||
embeddedAnnotations.length > embeddedAnnotationsTargetingThisPage.length | |||
) { | |||
const pageAsAnnotationSource: AnnotationSourceDescriptor = { | |||
title: document.title, | |||
url: document.URL.split('#')[0], | |||
type: 'embeddedJsonld', | |||
}; | |||
this.setState({ | |||
discoveredEmbeddedSources: await this.checkDiscoveredAnnotationSources([ | |||
pageAsAnnotationSource, | |||
]), | |||
}); | |||
} | |||
} | |||
async discoverLinkedAnnotationSources() { | |||
// Find annotations sources advertised by the current page. | |||
const discoveredSources = discoverAnnotationSources(); | |||
this.setState({ | |||
discoveredLinkedSources: await this.checkDiscoveredAnnotationSources( | |||
discoveredSources, | |||
), | |||
}); | |||
} | |||
async checkDiscoveredAnnotationSources( | |||
discoveredSources: AnnotationSourceDescriptor[], | |||
) { | |||
// For each discovered source, note if we already have it in our database. | |||
return await Promise.all( | |||
discoveredSources.map(async (source) => ({ | |||
...source, | |||
subscribed: await isSourceSubscribed(source), | |||
})), | |||
); | |||
} | |||
async onAnnotationCreated(annotation: IAnnotationWithSource) { | |||
await this.loadStoredAnnotations(); | |||
this.setState({ annotationInFocus: annotation }); | |||
} | |||
onSubscriptionChange() { | |||
this.discoverLinkedAnnotationSources(); | |||
this.discoverEmbeddedAnnotations(); | |||
} | |||
render( | |||
{ appContainer }: AppProps, | |||
{ | |||
storedAnnotations, | |||
embeddedAnnotations, | |||
annotationInFocus, | |||
discoveredLinkedSources, | |||
discoveredEmbeddedSources, | |||
}: AppState, | |||
) { | |||
const annotationsToShow = unique( | |||
[...storedAnnotations, ...embeddedAnnotations], | |||
(obj) => obj.annotation.canonical || obj.annotation.id, | |||
); | |||
const discoveredSources = [ | |||
...discoveredLinkedSources, | |||
...discoveredEmbeddedSources, | |||
]; | |||
const toolbarButtons = | |||
discoveredSources.length > 0 ? ( | |||
<ToolbarButtons | |||
{...{ | |||
onChange: () => this.onSubscriptionChange(), | |||
discoveredSources, | |||
}} | |||
/> | |||
) : undefined; | |||
return ( | |||
<> | |||
<MarginalAnnotations | |||
{...{ | |||
annotations: annotationsToShow, | |||
annotationInFocus, | |||
toolbarButtons, | |||
appContainer, | |||
onUpdateAnnotation: async (...args) => { | |||
await updateAnnotation(...args); | |||
// messes up text while editing. | |||
// await this.loadStoredAnnotations(); | |||
}, | |||
onDeleteAnnotation: async (...args) => { | |||
await deleteAnnotation(...args); | |||
await this.loadStoredAnnotations(); | |||
}, | |||
}} | |||
/> | |||
<AnnotationCreationHelper | |||
onAnnotationCreated={(annotation) => | |||
this.onAnnotationCreated(annotation) | |||
} | |||
/> | |||
</> | |||
); | |||
} | |||
} |
@@ -0,0 +1,132 @@ | |||
import cls from 'classnames'; | |||
import debounce from 'lodash.debounce'; | |||
import classes from './MarginalAnnotations.module.scss'; | |||
import { AnnotationBody } from './AnnotationBody'; | |||
import { Component, h } from 'preact'; | |||
import { getSingleCreatorName } from 'web-annotation-utils'; | |||
import type { WebAnnotation } from 'web-annotation-utils'; | |||
import type { IAnnotation, IAnnotationWithSource } from '../storage/Annotation'; | |||
export interface MarginalAnnotationCardProps { | |||
annotation: IAnnotationWithSource; | |||
anchorPosition?: number | undefined; | |||
extraClasses?: string; | |||
inFocus?: boolean; | |||
onUpdateAnnotation: ( | |||
id: IAnnotation['_id'], | |||
newWebAnnotation: WebAnnotation, | |||
) => Promise<void>; | |||
onDeleteAnnotation: (id: IAnnotation['_id']) => Promise<void>; | |||
[other: string]: any; | |||
} | |||
interface MarginalAnnotationCardState {} | |||
export class MarginalAnnotationCard extends Component< | |||
MarginalAnnotationCardProps, | |||
MarginalAnnotationCardState | |||
> { | |||
state = {}; | |||
onBodyChange = debounce( | |||
async (newBody: WebAnnotation['body']) => { | |||
const newWebAnnotation: WebAnnotation = { | |||
...this.props.annotation.annotation, | |||
bodyValue: undefined, | |||
body: newBody, | |||
modified: new Date().toISOString(), | |||
}; | |||
await this.props.onUpdateAnnotation( | |||
this.props.annotation._id, | |||
newWebAnnotation, | |||
); | |||
}, | |||
1_000, | |||
{ maxWait: 10_000 }, | |||
); | |||
render( | |||
{ | |||
annotation, | |||
anchorPosition, | |||
extraClasses, | |||
inFocus, | |||
...otherProps | |||
}: MarginalAnnotationCardProps, | |||
{}: MarginalAnnotationCardState, | |||
) { | |||
const editable = !!annotation.source.writable; | |||
const anchorPositionStyle = | |||
anchorPosition !== undefined | |||
? { | |||
style: { marginTop: anchorPosition }, | |||
} | |||
: {}; | |||
const annotationCard = ( | |||
<div | |||
class={classes.annotationWrapper} | |||
{...anchorPositionStyle} | |||
key={annotation._id} | |||
> | |||
<aside | |||
class={cls( | |||
classes.annotation, | |||
{ [classes.dirty]: annotation.dirty, [classes.editable]: editable }, | |||
extraClasses, | |||
)} | |||
{...otherProps} | |||
> | |||
<AnnotationBody | |||
body={annotation.annotation.body} | |||
bodyValue={annotation.annotation.bodyValue} | |||
editable={editable} | |||
onChange={(newBody) => this.onBodyChange(newBody)} | |||
onBlur={() => this.onBodyChange.flush()} | |||
inFocus={inFocus} | |||
/> | |||
<div class={classes.cardBottom}> | |||
<span class={classes.creator}> | |||
{getSingleCreatorName(annotation.annotation)} | |||
</span> | |||
<span> | |||
{annotation.dirty && ( | |||
<span | |||
title={`Your edits on this annotation have not yet been stored; if this problem persists, try manually refresh its annotation collection (“${annotation.source?.title}”).`} | |||
> | |||
🖉{' '} | |||
</span> | |||
)} | |||
{editable && ( | |||
<button | |||
onClick={() => | |||
this.props.onDeleteAnnotation(this.props.annotation._id) | |||
} | |||
class={classes.button} | |||
title="Delete this annotation" | |||
> | |||
🗑 | |||
</button> | |||
)} | |||
</span> | |||
</div> | |||
</aside> | |||
</div> | |||
); | |||
// If it has a specified vertical position, wrap it in a separate absolutely positioned container | |||
if (anchorPosition !== undefined) { | |||
return ( | |||
<div | |||
class={classes.singleAnnotationOuterContainer} | |||
key={annotation._id} | |||
> | |||
<div class={cls(classes.singleAnnotationContainer)}> | |||
{annotationCard} | |||
</div> | |||
</div> | |||
); | |||
} else { | |||
return annotationCard; | |||
} | |||
} | |||
} |
@@ -0,0 +1,183 @@ | |||
html { | |||
// Support hidden overflow into the right margin. | |||
// (would be better to not have to change the page’s style; but not sure how) | |||
position: relative; | |||
} | |||
html body { | |||
/* Increase specifity above that of our style reset */ | |||
.annotationOuterContainer { | |||
--container-width: 300px; | |||
--toolbar-height: 40px; | |||
z-index: 999998; | |||
position: absolute; | |||
top: 0; | |||
right: 0; | |||
bottom: 0; | |||
width: var(--container-width); | |||
max-width: 50vw; | |||
display: flex; | |||
flex-flow: column; | |||
pointer-events: none; | |||
box-sizing: border-box; | |||
font-family: sans-serif; | |||
} | |||
.singleAnnotationOuterContainer { | |||
position: absolute; | |||
top: 0; | |||
right: 0; | |||
bottom: 0; | |||
width: var(--container-width); | |||
max-width: 50vw; | |||
display: flex; | |||
flex-flow: column; | |||
pointer-events: none; | |||
box-sizing: border-box; | |||
} | |||
.annotationContainer { | |||
flex-grow: 1; | |||
pointer-events: none; | |||
& > *:not(.singleAnnotationOuterContainer) { | |||
pointer-events: all; | |||
} | |||
} | |||
.singleAnnotationContainer { | |||
flex-grow: 1; | |||
pointer-events: none; | |||
& .annotation { | |||
pointer-events: all; | |||
} | |||
} | |||
.annotationWrapper { | |||
overflow: hidden; | |||
position: sticky; // TODO Fix sticky position. | |||
top: --toolbar-height; | |||
bottom: 0; | |||
} | |||
.annotationContainer > :nth-last-child(2) .annotationWrapper { | |||
top: calc(var(--toolbar-height) + 4px); | |||
} | |||
.annotationContainer > :nth-last-child(1) .annotationWrapper { | |||
top: calc(var(--toolbar-height) + 8px); | |||
} | |||
.annotationContainer > :nth-child(1) .annotationWrapper { | |||
bottom: 8px; | |||
} | |||
.annotationContainer > :nth-child(2) .annotationWrapper { | |||
bottom: 4px; | |||
} | |||
.annotation { | |||
border: #999 1px solid; | |||
border-radius: 10px; | |||
box-shadow: 10px 10px 10px #9996; | |||
margin: 10px 0px; | |||
padding: 5px 40px 5px 10px; | |||
background-color: lightyellow; | |||
transition: transform 200ms, background-color 200ms; | |||
transform: translateX(calc(var(--container-width) - 40px)); | |||
&.dirty { | |||
opacity: 0.8; | |||
} | |||
.dirtyNotice { | |||
font-size: smaller; | |||
font-style: italic; | |||
} | |||
.cardBottom { | |||
padding-left: 10px; | |||
display: flex; | |||
justify-content: space-between; | |||
} | |||
.creator { | |||
font-style: italic; | |||
} | |||
} | |||
.annotationContainer.showAll .annotation, | |||
.annotation.highlightClicked, | |||
.annotation.highlightHovered, | |||
.annotation:hover, | |||
.annotation:focus-within { | |||
transform: none; | |||
} | |||
.annotation button, | |||
.toolbar button { | |||
font-size: 1em; | |||
border: none; | |||
background: none; | |||
cursor: pointer; | |||
&:hover { | |||
background: yellow; | |||
} | |||
.icon { | |||
width: 0.9em; | |||
} | |||
} | |||
.toolbarContainer { | |||
z-index: 999999; | |||
position: sticky; | |||
top: 0; | |||
display: flex; | |||
justify-content: end; | |||
.toolbar { | |||
max-width: var(--container-width); | |||
display: flex; | |||
flex-flow: column nowrap; | |||
align-items: flex-end; | |||
background-color: lightyellow; | |||
border-bottom-left-radius: 10px; | |||
border-left: #999 1px solid; | |||
border-bottom: #999 1px solid; | |||
box-shadow: 10px 10px 10px #9996; | |||
overflow: hidden; | |||
& > * { | |||
padding: 10px; | |||
} | |||
details { | |||
font-size: smaller; | |||
} | |||
summary { | |||
font-size: larger; | |||
list-style: none; | |||
cursor: pointer; | |||
} | |||
} | |||
} | |||
.hiddenWhenClosed { | |||
display: none; | |||
} | |||
.annotationContainer.showAll .hiddenWhenClosed { | |||
display: unset; | |||
} | |||
mark.highlight { | |||
background: #ff99; | |||
} | |||
mark.highlight.clicked, | |||
.annotation.highlightClicked, | |||
.showAll .showAllButton { | |||
background: #ff9; | |||
} | |||
mark.highlight.hovered, | |||
.annotation.highlightHovered { | |||
background: yellow; | |||
} | |||
} |
@@ -0,0 +1,82 @@ | |||
import { h, Component, ComponentChildren } from 'preact'; | |||
import classes from './MarginalAnnotations.module.scss'; | |||
import cls from 'classnames'; | |||
import { AnnotationTargetHighlight } from './AnnotationTargetHighlight'; | |||
import type { IAnnotation, IAnnotationWithSource } from '../storage/Annotation'; | |||
import { WebAnnotation } from 'web-annotation-utils'; | |||
interface MarginalAnnotationsProps { | |||
appContainer: Node; | |||
annotations: IAnnotationWithSource[]; | |||
annotationInFocus?: IAnnotationWithSource; | |||
toolbarButtons?: ComponentChildren; | |||
onUpdateAnnotation: ( | |||
id: IAnnotation['_id'], | |||
newWebAnnotation: WebAnnotation, | |||
) => Promise<void>; | |||
onDeleteAnnotation: (id: IAnnotation['_id']) => Promise<void>; | |||
} | |||
interface MarginalAnnotationsState { | |||
showAll: boolean; | |||
} | |||
export class MarginalAnnotations extends Component< | |||
MarginalAnnotationsProps, | |||
MarginalAnnotationsState | |||
> { | |||
state: MarginalAnnotationsState = { showAll: false }; | |||
toggleShowAll = () => { | |||
this.setState(({ showAll }) => ({ showAll: !showAll })); | |||
}; | |||
render( | |||
{ | |||
annotations, | |||
annotationInFocus, | |||
toolbarButtons, | |||
appContainer, | |||
onUpdateAnnotation, | |||
onDeleteAnnotation, | |||
}: MarginalAnnotationsProps, | |||
{ showAll }: MarginalAnnotationsState, | |||
) { | |||
const annotationElements = annotations.map((annotation) => ( | |||
<AnnotationTargetHighlight | |||
key={annotation._id} | |||
annotation={annotation} | |||
appContainer={appContainer} | |||
inFocus={annotation._id === annotationInFocus?._id} | |||
onUpdateAnnotation={onUpdateAnnotation} | |||
onDeleteAnnotation={onDeleteAnnotation} | |||
/> | |||
)); | |||
return ( | |||
<div class={classes.annotationOuterContainer}> | |||
<div | |||
class={cls(classes.annotationContainer, { | |||
[classes.showAll]: showAll, | |||
})} | |||
> | |||
<div class={classes.toolbarContainer}> | |||
<div class={classes.toolbar}> | |||
{toolbarButtons} | |||
{(toolbarButtons || annotations.length > 0) && ( | |||
<button | |||
class={classes.showAllButton} | |||
onClick={this.toggleShowAll} | |||
title="Show/hide all annotations" | |||
> | |||
{showAll ? '❯' : '❮'} | |||
</button> | |||
)} | |||
</div> | |||
</div> | |||
{annotationElements} | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,133 @@ | |||
import { h, Component, ComponentChild } from 'preact'; | |||
import classes from './MarginalAnnotations.module.scss'; | |||
import type { backgroundRpcServer } from '../background'; | |||
import type { AnnotationSourceDescriptor } from '../storage/AnnotationSource'; | |||
import { RpcClient } from 'webextension-rpc'; | |||
import rssIcon from '../assets/icons/rss.svg'; | |||
import infoIcon from '../assets/icons/info.svg'; | |||
import downloadIcon from '../assets/icons/download.svg'; | |||
const backgroundRpc = new RpcClient<typeof backgroundRpcServer>(); | |||
const addAnnotationSource = backgroundRpc.func('addAnnotationSource'); | |||
const removeSource = backgroundRpc.func('removeSource'); | |||
const refreshAnnotationSource = backgroundRpc.func('refreshAnnotationSource'); | |||
interface ToolbarButtonsProps { | |||
discoveredSources: (AnnotationSourceDescriptor & { subscribed: boolean })[]; | |||
onChange: () => void; | |||
} | |||
interface ToolbarButtonsState { | |||
showAll: boolean; | |||
} | |||
export class ToolbarButtons extends Component< | |||
ToolbarButtonsProps, | |||
ToolbarButtonsState | |||
> { | |||
render( | |||
{ discoveredSources, onChange }: ToolbarButtonsProps, | |||
{}: ToolbarButtonsState, | |||
) { | |||
const buttons: ComponentChild[] = []; | |||
for (const source of discoveredSources) { | |||
let explanationSummary, explanationFull; | |||
if (source.type === 'container') { | |||
explanationSummary = ( | |||
<span> | |||
<img src={rssIcon} /> This page provides an annotation source{' '} | |||
<i>“{source.title}”</i>. <img src={infoIcon} /> | |||
</span> | |||
); | |||
explanationFull = `If you subscribe to it, annotations published by this website will be stored in your browser, and displayed on the web pages they target. Its annotations are automatically refreshed periodically.`; | |||
} else if (source.type === 'embeddedJsonld') { | |||
explanationSummary = ( | |||
<span> | |||
This page contains embedded annotations. <img src={infoIcon} /> | |||
</span> | |||
); | |||
explanationFull = `If you import them, they will be stored in your browser, and displayed on the web pages they target. Note they will not be refreshed automatically.`; | |||
} | |||
buttons.push( | |||
<details class={classes.hiddenWhenClosed}> | |||
<summary>{explanationSummary}</summary> | |||
{explanationFull} | |||
</details>, | |||
); | |||
if (source.subscribed) { | |||
if (source.type === 'container') { | |||
buttons.push( | |||
<button | |||
title="Unsubscribe from this annotation source (forget its annotations)" | |||
onClick={async () => { | |||
await removeSource(source); | |||
onChange(); | |||
}} | |||
> | |||
❌<span class={classes.hiddenWhenClosed}> Unsubscribe</span> | |||
</button>, | |||
<button | |||
title="Refresh annotations from this source." | |||
onClick={() => refreshAnnotationSource(source, true)} | |||
> | |||
🗘<span class={classes.hiddenWhenClosed}> Refresh</span> | |||
</button>, | |||
); | |||
} else if (source.type === 'embeddedJsonld') { | |||
buttons.push( | |||
<button | |||
title="Remove annotations from this source." | |||
onClick={async () => { | |||
await removeSource(source); | |||
onChange(); | |||
}} | |||
> | |||
❌<span class={classes.hiddenWhenClosed}> Remove</span> | |||
</button>, | |||
<button | |||
title="Refresh annotations from this source." | |||
onClick={() => refreshAnnotationSource(source, true)} | |||
> | |||
🗘<span class={classes.hiddenWhenClosed}> Refresh</span> | |||
</button>, | |||
); | |||
} | |||
} else { | |||
if (source.type === 'container') { | |||
buttons.push( | |||
<button | |||
onClick={async () => { | |||
await addAnnotationSource(source); | |||
onChange(); | |||
}} | |||
title="Subscribe to annotations from this website." | |||
> | |||
<img src={rssIcon} class={classes.icon} /> | |||
<span class={classes.hiddenWhenClosed}> Subscribe</span> | |||
</button>, | |||
); | |||
} else if (source.type === 'embeddedJsonld') { | |||
buttons.push( | |||
<button | |||
onClick={async () => { | |||
await addAnnotationSource(source); | |||
onChange(); | |||
}} | |||
title="Import the annotations embedded in this page." | |||
> | |||
<img src={downloadIcon} class={classes.icon} /> | |||
<span class={classes.hiddenWhenClosed}> Import annotations</span> | |||
</button>, | |||
); | |||
} | |||
} | |||
} | |||
return buttons.length > 0 ? buttons : null; | |||
} | |||
} | |||
// TODO Refresh components properly. instead of reload the whole page. :) | |||
function pageReload() { | |||
window.location = window.location; | |||
} |
@@ -0,0 +1,18 @@ | |||
import type { AnnotationSourceDescriptor } from '../storage/AnnotationSource'; | |||
import { discoverAnnotationFeeds, discoverEmbeddedAnnotations } from '../discovery'; | |||
import { WebAnnotation } from 'web-annotation-utils'; | |||
export function discoverAnnotationsEmbeddedAsJSONLD(): WebAnnotation[] { | |||
// TODO data validation | |||
return discoverEmbeddedAnnotations() as WebAnnotation[]; | |||
}; | |||
// Find any link like this: <link rel="alternate" type='application/ld+json;profile="http://www.w3.org/ns/anno.jsonld"'> | |||
export function discoverAnnotationSources( | |||
): AnnotationSourceDescriptor[] { | |||
return discoverAnnotationFeeds().map(({ url, title }) => ({ | |||
url, | |||
title, | |||
type: 'container', | |||
})); | |||
} |
@@ -0,0 +1,5 @@ | |||
/* Protect our widgets from page style rules (but this won’t work if those rules have higher specificity..). */ | |||
.container, | |||
.container * { | |||
all: revert; | |||
} |
@@ -0,0 +1,47 @@ | |||
import '../webextension-polyfill'; | |||
import { RpcServer } from 'webextension-rpc'; | |||