Browser extension that demonstrates the Web Annotation Discovery mechanism: subscribe to people’s annotation collections/‘feeds’, to see their notes on the web; and create & publish annotations yourself.
Browse Source

Initial publication

tags/v0.1.0
Gerben 1 year ago
commit
6d24bb334a
58 changed files with 22988 additions and 0 deletions
  1. +3
    -0
      .gitignore
  2. +77
    -0
      Readme.md
  3. +19497
    -0
      package-lock.json
  4. +38
    -0
      package.json
  5. +14
    -0
      src/_locales/en/messages.json
  6. +41
    -0
      src/assets/icon/icon.svg
  7. BIN
      src/assets/icon/icon128.png
  8. BIN
      src/assets/icon/icon256.png
  9. BIN
      src/assets/icon/icon48.png
  10. BIN
      src/assets/icon/icon96.png
  11. +5
    -0
      src/assets/icons/download.svg
  12. +5
    -0
      src/assets/icons/info.svg
  13. +6
    -0
      src/assets/icons/refresh.svg
  14. +5
    -0
      src/assets/icons/rss.svg
  15. +35
    -0
      src/background/context-menu.ts
  16. +60
    -0
      src/background/detect-annotation.ts
  17. +84
    -0
      src/background/index.ts
  18. +42
    -0
      src/common-classes.scss
  19. +53
    -0
      src/components/AnnotationSourcesList.module.scss
  20. +173
    -0
      src/components/AnnotationSourcesList.tsx
  21. +22
    -0
      src/components/AnnotationsList.module.scss
  22. +93
    -0
      src/components/AnnotationsList.tsx
  23. +25
    -0
      src/content_script/AnnotationBody.module.scss
  24. +164
    -0
      src/content_script/AnnotationBody.tsx
  25. +72
    -0
      src/content_script/AnnotationCreationHelper.tsx
  26. +232
    -0
      src/content_script/AnnotationTargetHighlight.tsx
  27. +204
    -0
      src/content_script/App.tsx
  28. +132
    -0
      src/content_script/MarginalAnnotationCard.tsx
  29. +183
    -0
      src/content_script/MarginalAnnotations.module.scss
  30. +82
    -0
      src/content_script/MarginalAnnotations.tsx
  31. +133
    -0
      src/content_script/ToolbarButtons.tsx
  32. +18
    -0
      src/content_script/discovery.ts
  33. +5
    -0
      src/content_script/index.module.scss
  34. +47
    -0
      src/content_script/index.tsx
  35. +14
    -0
      src/css-modules.d.ts
  36. +169
    -0
      src/discovery.ts
  37. +44
    -0
      src/manifest.json.ts
  38. +10
    -0
      src/overview/Overview.module.scss
  39. +19
    -0
      src/overview/Overview.tsx
  40. +3
    -0
      src/overview/index.html
  41. +9
    -0
      src/overview/index.tsx
  42. +12
    -0
      src/page-style.scss
  43. +1
    -0
      src/popup/AnnotationStoreSelector.module.scss
  44. +145
    -0
      src/popup/AnnotationStoreSelector.tsx
  45. +15
    -0
      src/popup/Popup.module.scss
  46. +33
    -0
      src/popup/Popup.tsx
  47. +4
    -0
      src/popup/index.html
  48. +9
    -0
      src/popup/index.tsx
  49. +119
    -0
      src/storage/Annotation.ts
  50. +482
    -0
      src/storage/AnnotationSource.ts
  51. +18
    -0
      src/storage/db.ts
  52. +150
    -0
      src/util/dom-selectors.ts
  53. +52
    -0
      src/util/niceTime.ts
  54. +49
    -0
      src/util/notify.ts
  55. +34
    -0
      src/util/unique.ts
  56. +2
    -0
      src/webextension-polyfill.ts
  57. +16
    -0
      tsconfig.json
  58. +29
    -0
      vite.config.ts

+ 3
- 0
.gitignore View File

@@ -0,0 +1,3 @@
/dist
/web-ext-artifacts
/node_modules

+ 77
- 0
Readme.md View File

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

+ 19497
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 38
- 0
package.json View File

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

+ 14
- 0
src/_locales/en/messages.json View File

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

+ 41
- 0
src/assets/icon/icon.svg View File

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

BIN
src/assets/icon/icon128.png View File

Before After
Width: 128  |  Height: 128  |  Size: 2.8 KiB

BIN
src/assets/icon/icon256.png View File

Before After
Width: 256  |  Height: 256  |  Size: 5.7 KiB

BIN
src/assets/icon/icon48.png View File

Before After
Width: 48  |  Height: 48  |  Size: 1.1 KiB

BIN
src/assets/icon/icon96.png View File

Before After
Width: 96  |  Height: 96  |  Size: 2.0 KiB

+ 5
- 0
src/assets/icons/download.svg View File

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

+ 5
- 0
src/assets/icons/info.svg View File

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

+ 6
- 0
src/assets/icons/refresh.svg View File

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

+ 5
- 0
src/assets/icons/rss.svg View File

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

+ 35
- 0
src/background/context-menu.ts View File

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

+ 60
- 0
src/background/detect-annotation.ts View File

@@ -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'] }],
});

+ 84
- 0
src/background/index.ts View 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();
}

+ 42
- 0
src/common-classes.scss View File

@@ -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: ' ✔️';
}
}
}

+ 53
- 0
src/components/AnnotationSourcesList.module.scss View File

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

+ 173
- 0
src/components/AnnotationSourcesList.tsx View File

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

+ 22
- 0
src/components/AnnotationsList.module.scss View File

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

+ 93
- 0
src/components/AnnotationsList.tsx View File

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

+ 25
- 0
src/content_script/AnnotationBody.module.scss View File

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

+ 164
- 0
src/content_script/AnnotationBody.tsx View File

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

+ 72
- 0
src/content_script/AnnotationCreationHelper.tsx View File

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

+ 232
- 0
src/content_script/AnnotationTargetHighlight.tsx View File

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

+ 204
- 0
src/content_script/App.tsx View File

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

+ 132
- 0
src/content_script/MarginalAnnotationCard.tsx View File

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

+ 183
- 0
src/content_script/MarginalAnnotations.module.scss View File

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

+ 82
- 0
src/content_script/MarginalAnnotations.tsx View File

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

+ 133
- 0
src/content_script/ToolbarButtons.tsx View File

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

+ 18
- 0
src/content_script/discovery.ts View File

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

+ 5
- 0
src/content_script/index.module.scss View File

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

+ 47
- 0
src/content_script/index.tsx View File

@@ -0,0 +1,47 @@
import '../webextension-polyfill';
import { RpcServer } from 'webextension-rpc';