@projektemacher/storymapjs 0.10.6 → 0.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG +11 -0
- package/dist/assets/{demo-CVQ7kFek.js → demo-D6cTmzn6.js} +1 -1
- package/dist/assets/{harness-C-VZV-ou.js → harness-BPFlzutp.js} +1 -1
- package/dist/assets/main-Y-MYUSJ4.js +17 -0
- package/dist/css/fonts/font.oldstandard.css +18 -22
- package/dist/demo.html +2 -2
- package/dist/docs/migration.html +1 -1
- package/dist/embed/index.html +129 -4
- package/dist/harness.html +2 -2
- package/dist/index.html +17 -0
- package/dist/js/storymap.d.ts +126 -7
- package/dist/js/storymap.js +4518 -3613
- package/dist/js/storymap.js.map +1 -1
- package/docs/migration-from-knightlab.md +139 -0
- package/docs/storymap-as-iiif-manifest.md +510 -0
- package/package.json +6 -3
- package/src/animation/easings.ts +18 -0
- package/src/animation/morpheus.d.ts +41 -0
- package/src/core/Browser.ts +19 -0
- package/src/core/Load.ts +180 -0
- package/src/core/Util.ts +354 -0
- package/src/core/mixins.ts +171 -0
- package/src/dom/Dom.ts +31 -0
- package/src/dom/DomEvent.ts +61 -0
- package/src/globals.d.ts +16 -0
- package/src/language/Language.ts +107 -0
- package/src/language/locale/be.json +18 -0
- package/src/language/locale/bg.json +18 -0
- package/src/language/locale/cs.json +18 -0
- package/src/language/locale/de.json +20 -0
- package/src/language/locale/el.json +18 -0
- package/src/language/locale/en.json +28 -0
- package/src/language/locale/es.json +18 -0
- package/src/language/locale/et.json +18 -0
- package/src/language/locale/fr.json +18 -0
- package/src/language/locale/he.json +18 -0
- package/src/language/locale/hu.json +18 -0
- package/src/language/locale/is.json +18 -0
- package/src/language/locale/it.json +18 -0
- package/src/language/locale/jp.json +18 -0
- package/src/language/locale/ko.json +18 -0
- package/src/language/locale/nl.json +18 -0
- package/src/language/locale/nn.json +18 -0
- package/src/language/locale/no.json +18 -0
- package/src/language/locale/pl.json +18 -0
- package/src/language/locale/pt.json +18 -0
- package/src/language/locale/ru.json +18 -0
- package/src/language/locale/sk.json +18 -0
- package/src/language/locale/sr.json +17 -0
- package/src/language/locale/sv.json +18 -0
- package/src/language/locale/tr.json +18 -0
- package/src/language/locale/uk.json +18 -0
- package/src/language/locale/ur.json +18 -0
- package/src/language/locale/zh-cn.json +18 -0
- package/src/language/locale/zh-tw.json +18 -0
- package/src/main.ts +51 -0
- package/src/map/Map.ts +717 -0
- package/src/map/MapMarker.ts +149 -0
- package/src/map/openlayers/Map.OpenLayers.ts +1879 -0
- package/src/map/openlayers/MapMarker.OpenLayers.ts +176 -0
- package/src/map/openlayers/zoomifyTiles.ts +28 -0
- package/src/map/types.ts +17 -0
- package/src/media/EmbedUtil.ts +168 -0
- package/src/media/Media.ts +409 -0
- package/src/media/MediaType.ts +163 -0
- package/src/media/types/Audio.ts +87 -0
- package/src/media/types/Blockquote.ts +40 -0
- package/src/media/types/DailyMotion.ts +58 -0
- package/src/media/types/DocumentCloud.ts +41 -0
- package/src/media/types/Facebook.ts +41 -0
- package/src/media/types/Flickr.ts +115 -0
- package/src/media/types/GoogleDoc.ts +59 -0
- package/src/media/types/IFrame.ts +51 -0
- package/src/media/types/Image.ts +49 -0
- package/src/media/types/Juxtapose.ts +32 -0
- package/src/media/types/SoundCloud.ts +77 -0
- package/src/media/types/Text.ts +172 -0
- package/src/media/types/Twitter.ts +96 -0
- package/src/media/types/Video.ts +84 -0
- package/src/media/types/Vimeo.ts +65 -0
- package/src/media/types/Website.ts +36 -0
- package/src/media/types/Wikipedia.ts +124 -0
- package/src/media/types/YouTube.ts +192 -0
- package/src/scss/Typography.scss +237 -0
- package/src/scss/VCO.StoryMap.Dark.scss +138 -0
- package/src/scss/VCO.StoryMap.scss +164 -0
- package/src/scss/Variables.Dark.scss +56 -0
- package/src/scss/Variables.scss +47 -0
- package/src/scss/core/Mixins.scss +112 -0
- package/src/scss/core/Reset.scss +206 -0
- package/src/scss/fonts/_font.base.scss +113 -0
- package/src/scss/fonts/font.abril-droidsans.scss +75 -0
- package/src/scss/fonts/font.amatic-andika.scss +73 -0
- package/src/scss/fonts/font.bitter-raleway.scss +86 -0
- package/src/scss/fonts/font.clicker-garamond.scss +92 -0
- package/src/scss/fonts/font.dancing-ledger.scss +74 -0
- package/src/scss/fonts/font.default.scss +9 -0
- package/src/scss/fonts/font.fjalla-average.scss +80 -0
- package/src/scss/fonts/font.georgia-helvetica.scss +58 -0
- package/src/scss/fonts/font.knightlab.scss +97 -0
- package/src/scss/fonts/font.lustria-lato.scss +80 -0
- package/src/scss/fonts/font.medula-lato.scss +73 -0
- package/src/scss/fonts/font.oldstandard.scss +88 -0
- package/src/scss/fonts/font.opensans-gentiumbook.scss +80 -0
- package/src/scss/fonts/font.playfair-faunaone.scss +86 -0
- package/src/scss/fonts/font.playfair.scss +85 -0
- package/src/scss/fonts/font.pt.scss +90 -0
- package/src/scss/fonts/font.roboto-megrim.scss +73 -0
- package/src/scss/fonts/font.rufina-sintony.scss +73 -0
- package/src/scss/fonts/font.ubuntu.scss +78 -0
- package/src/scss/fonts/font.unicaone-vollkorn.scss +85 -0
- package/src/scss/icons/Icons.scss +239 -0
- package/src/scss/map/VCO.ImageMarker.scss +23 -0
- package/src/scss/map/VCO.Map.scss +21 -0
- package/src/scss/map/VCO.MapMarker.scss +147 -0
- package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +126 -0
- package/src/scss/media/VCO.Media.scss +222 -0
- package/src/scss/media/types/VCO.Media.Blockquote.scss +76 -0
- package/src/scss/media/types/VCO.Media.IFrame.scss +11 -0
- package/src/scss/media/types/VCO.Media.Image.scss +13 -0
- package/src/scss/media/types/VCO.Media.Instagram.scss +7 -0
- package/src/scss/media/types/VCO.Media.Profile.scss +5 -0
- package/src/scss/media/types/VCO.Media.Text.scss +90 -0
- package/src/scss/media/types/VCO.Media.Twitter.scss +87 -0
- package/src/scss/media/types/VCO.Media.Wikipedia.scss +64 -0
- package/src/scss/site/site.scss +748 -0
- package/src/scss/slider/VCO.Slide.scss +447 -0
- package/src/scss/slider/VCO.SlideNav.scss +338 -0
- package/src/scss/slider/VCO.StorySlider.scss +64 -0
- package/src/scss/snapmap/VCO.SnapMap.scss +103 -0
- package/src/scss/ui/VCO.Consent.scss +77 -0
- package/src/scss/ui/VCO.MenuBar.Button.scss +99 -0
- package/src/scss/ui/VCO.MenuBar.scss +20 -0
- package/src/scss/ui/VCO.Message.scss +104 -0
- package/src/site/site.ts +87 -0
- package/src/slider/Slide.ts +460 -0
- package/src/slider/SlideNav.ts +212 -0
- package/src/slider/StorySlider.ts +722 -0
- package/src/storymap/Consent.ts +146 -0
- package/src/storymap/StoryMap.ts +1108 -0
- package/src/storymap/iiif.ts +386 -0
- package/src/storymap/validate.ts +184 -0
- package/src/types.ts +297 -0
- package/src/ui/Draggable.ts +374 -0
- package/src/ui/MenuBar.ts +323 -0
- package/src/ui/Message.ts +118 -0
- package/src/ui/Swipable.ts +388 -0
- package/dist/assets/main-DwbXrrYn.js +0 -17
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import Overlay from "ol/Overlay";
|
|
2
|
+
import { fromLonLat } from "ol/proj";
|
|
3
|
+
import type { Map as OlMap } from "ol";
|
|
4
|
+
import MapMarker from "../MapMarker";
|
|
5
|
+
import type { LatLngLiteral, MapMarkerData, StorymapOptions } from "../../types";
|
|
6
|
+
|
|
7
|
+
/* MapMarker.OpenLayers
|
|
8
|
+
Produces a marker for OpenLayers maps.
|
|
9
|
+
|
|
10
|
+
Default and image markers are rendered as HTML overlays so the
|
|
11
|
+
existing .vco-mapmarker styles apply unchanged.
|
|
12
|
+
================================================= */
|
|
13
|
+
|
|
14
|
+
export default class OpenLayersMapMarker extends MapMarker {
|
|
15
|
+
declare "_overlay": Overlay;
|
|
16
|
+
|
|
17
|
+
/* Create Marker
|
|
18
|
+
================================================== */
|
|
19
|
+
_createMarker(d?: MapMarkerData, o?: StorymapOptions): void {
|
|
20
|
+
if (d.location && typeof d.location.lat == "number" && typeof d.location.lon == "number") {
|
|
21
|
+
this.data.real_marker = true;
|
|
22
|
+
const use_custom_marker = o.use_custom_markers || d.location.use_custom_marker;
|
|
23
|
+
if (use_custom_marker && d.location.icon) {
|
|
24
|
+
this._custom_icon = {
|
|
25
|
+
url: d.location.icon,
|
|
26
|
+
size: d.location.iconSize || [48, 48],
|
|
27
|
+
anchor: this._customIconAnchor(d.location.iconSize),
|
|
28
|
+
};
|
|
29
|
+
} else if (use_custom_marker && d.location.image) {
|
|
30
|
+
this._custom_image_icon = d.location.image;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this._marker = this._createMarkerElement(d, o);
|
|
34
|
+
this._marker.addEventListener("click", (e) => {
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
this._onMarkerClick(e);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_createMarkerElement(d: MapMarkerData, o?: StorymapOptions): HTMLDivElement {
|
|
42
|
+
const el = document.createElement("div");
|
|
43
|
+
el.title = d.text && d.text.headline ? d.text.headline : "";
|
|
44
|
+
|
|
45
|
+
if (this._custom_icon) {
|
|
46
|
+
// custom icon markers render only their image (like the
|
|
47
|
+
// original Leaflet L.icon): no vco-mapmarker class, otherwise
|
|
48
|
+
// the default pin glyph (::before) would show next to the image
|
|
49
|
+
el.className = "vco-mapmarker-custom";
|
|
50
|
+
const img = document.createElement("img");
|
|
51
|
+
img.src = this._custom_icon.url;
|
|
52
|
+
img.style.width = this._custom_icon.size[0] + "px";
|
|
53
|
+
img.style.height = "auto";
|
|
54
|
+
el.appendChild(img);
|
|
55
|
+
el.style.marginLeft = -this._custom_icon.anchor[0] + "px";
|
|
56
|
+
el.style.marginTop = -this._custom_icon.anchor[1] + "px";
|
|
57
|
+
} else if (this._custom_image_icon) {
|
|
58
|
+
el.className = "vco-mapmarker-image-icon";
|
|
59
|
+
const img = document.createElement("img");
|
|
60
|
+
img.src = this._custom_image_icon;
|
|
61
|
+
img.style.width = "48px";
|
|
62
|
+
img.style.height = "auto";
|
|
63
|
+
el.appendChild(img);
|
|
64
|
+
el.style.marginLeft = "-24px";
|
|
65
|
+
el.style.marginTop = "-48px";
|
|
66
|
+
} else {
|
|
67
|
+
el.className = "vco-mapmarker " + this.media_icon_class;
|
|
68
|
+
}
|
|
69
|
+
return el;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_addTo(m: OlMap): void {
|
|
73
|
+
if (this.data.real_marker) {
|
|
74
|
+
const d = this.data;
|
|
75
|
+
// Image-space maps (IIIF) use EPSG:4326 with raw image pixel coordinates
|
|
76
|
+
const is_image_space = m.getView().getProjection().getCode() === "EPSG:4326";
|
|
77
|
+
const position = is_image_space
|
|
78
|
+
? [d.location.lon, d.location.lat]
|
|
79
|
+
: fromLonLat([d.location.lon, d.location.lat]);
|
|
80
|
+
|
|
81
|
+
// Default pins anchor on their tip: OL's inline styles override
|
|
82
|
+
// any CSS top/left, so the alignment is done here — bottom-center
|
|
83
|
+
// positioning plus a 1px offset lands the pin tip (the bottommost
|
|
84
|
+
// glyph pixel, measured against the coordinate via
|
|
85
|
+
// getPixelFromCoordinate) exactly on the coordinate, so the route
|
|
86
|
+
// lines meet the markers' tips.
|
|
87
|
+
const is_default_pin = !this._custom_icon && !this._custom_image_icon;
|
|
88
|
+
this._overlay = new Overlay({
|
|
89
|
+
element: this._marker,
|
|
90
|
+
position: position,
|
|
91
|
+
stopEvent: false,
|
|
92
|
+
...(is_default_pin ? { positioning: "bottom-center", offset: [0, 1] } : {}),
|
|
93
|
+
});
|
|
94
|
+
m.addOverlay(this._overlay);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_removeFrom(m: OlMap): void {
|
|
99
|
+
if (this.data.real_marker && this._overlay) {
|
|
100
|
+
m.removeOverlay(this._overlay);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_active(a: boolean): void {
|
|
105
|
+
if (this.data.media && this.data.media.mediatype) {
|
|
106
|
+
this.media_icon_class = "vco-mapmarker-icon vco-icon-" + this.data.media.mediatype.type;
|
|
107
|
+
} else {
|
|
108
|
+
this.media_icon_class = "vco-mapmarker-icon vco-icon-plaintext";
|
|
109
|
+
}
|
|
110
|
+
if (this.data.real_marker) {
|
|
111
|
+
if (this._custom_icon) {
|
|
112
|
+
// custom icons look the same active or not (as in the
|
|
113
|
+
// original Leaflet version): only the stacking changes
|
|
114
|
+
if (!a) {
|
|
115
|
+
clearTimeout(this.timer);
|
|
116
|
+
}
|
|
117
|
+
this._marker.style.zIndex = a ? "1000" : "";
|
|
118
|
+
} else if (this._custom_image_icon) {
|
|
119
|
+
if (a) {
|
|
120
|
+
this._marker.classList.remove("vco-mapmarker-image-icon");
|
|
121
|
+
this._marker.classList.add("vco-mapmarker-image-icon-active");
|
|
122
|
+
this._marker.style.zIndex = "1000";
|
|
123
|
+
} else {
|
|
124
|
+
clearTimeout(this.timer);
|
|
125
|
+
this._marker.classList.remove("vco-mapmarker-image-icon-active");
|
|
126
|
+
this._marker.classList.add("vco-mapmarker-image-icon");
|
|
127
|
+
this._marker.style.zIndex = "";
|
|
128
|
+
}
|
|
129
|
+
} else if (a) {
|
|
130
|
+
this._marker.classList.remove("vco-mapmarker");
|
|
131
|
+
this._marker.classList.add("vco-mapmarker-active");
|
|
132
|
+
this._marker.style.zIndex = "1000";
|
|
133
|
+
} else {
|
|
134
|
+
clearTimeout(this.timer);
|
|
135
|
+
this._marker.classList.remove("vco-mapmarker-active");
|
|
136
|
+
this._marker.classList.add("vco-mapmarker");
|
|
137
|
+
this._marker.style.zIndex = "";
|
|
138
|
+
}
|
|
139
|
+
// place-name label on the active marker (issue #243)
|
|
140
|
+
const old_label = this._marker.querySelector(".vco-marker-label");
|
|
141
|
+
if (old_label) {
|
|
142
|
+
old_label.remove();
|
|
143
|
+
}
|
|
144
|
+
if (a && this.options.marker_labels && this.data.text?.headline) {
|
|
145
|
+
const label = document.createElement("div");
|
|
146
|
+
label.className = "vco-marker-label";
|
|
147
|
+
label.textContent = this.data.text.headline;
|
|
148
|
+
this._marker.appendChild(label);
|
|
149
|
+
}
|
|
150
|
+
// refresh media icon class
|
|
151
|
+
const icon_el = this._marker.querySelector(".vco-mapmarker-icon");
|
|
152
|
+
if (icon_el) {
|
|
153
|
+
icon_el.className = this.media_icon_class;
|
|
154
|
+
} else if (!this._custom_icon && !this._custom_image_icon) {
|
|
155
|
+
this._marker.className =
|
|
156
|
+
(a ? "vco-mapmarker-active " : "vco-mapmarker ") + this.media_icon_class;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_customIconAnchor(size?: number[]): number[] {
|
|
162
|
+
if (size) {
|
|
163
|
+
return [size[0] * 0.5, size[1]];
|
|
164
|
+
} else {
|
|
165
|
+
return [24, 48];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_location(): LatLngLiteral {
|
|
170
|
+
if (this.data.real_marker) {
|
|
171
|
+
return { lat: this.data.location.lat, lon: this.data.location.lon };
|
|
172
|
+
} else {
|
|
173
|
+
return {} as LatLngLiteral;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type ImageTile from "ol/ImageTile";
|
|
2
|
+
|
|
3
|
+
/** Zoomify tiles are always addressed on a 256px grid. */
|
|
4
|
+
export const ZOOMIFY_TILE_SIZE = 256;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pad a cropped Zoomify edge tile onto a full tile canvas.
|
|
8
|
+
*
|
|
9
|
+
* Zoomify edge tiles are cropped to the image bounds (e.g. a 256x19 bottom
|
|
10
|
+
* strip), but OpenLayers draws the loaded image over the whole 256x256 tile
|
|
11
|
+
* box — stretching those strips across the cell (the smeared bottom in the
|
|
12
|
+
* Bosch overview, stretched right/bottom edges in the Literary Trail). The
|
|
13
|
+
* crop is top-left aligned in the tile cell, so draw it at (0, 0) and leave
|
|
14
|
+
* the rest transparent: the per-tile clamp of the original Leaflet renderer.
|
|
15
|
+
* Full-size tiles are left untouched for OpenLayers' own load handling.
|
|
16
|
+
*/
|
|
17
|
+
export function padCroppedZoomifyTile(tile: ImageTile, image: HTMLImageElement): void {
|
|
18
|
+
if (
|
|
19
|
+
image.naturalWidth !== ZOOMIFY_TILE_SIZE ||
|
|
20
|
+
image.naturalHeight !== ZOOMIFY_TILE_SIZE
|
|
21
|
+
) {
|
|
22
|
+
const canvas = document.createElement("canvas");
|
|
23
|
+
canvas.width = ZOOMIFY_TILE_SIZE;
|
|
24
|
+
canvas.height = ZOOMIFY_TILE_SIZE;
|
|
25
|
+
canvas.getContext("2d")?.drawImage(image, 0, 0);
|
|
26
|
+
tile.setImage(canvas);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/map/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Shared types for the map classes (src/map).
|
|
2
|
+
|
|
3
|
+
import type { StorymapSlideLocation } from "../types";
|
|
4
|
+
|
|
5
|
+
/** Options accepted by viewTo/_viewTo. */
|
|
6
|
+
export interface ViewToOptions {
|
|
7
|
+
calculate_zoom?: boolean;
|
|
8
|
+
duration?: number;
|
|
9
|
+
zoom?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A point of the connection line: either a slide or a raw lat/lon pair. */
|
|
13
|
+
export interface LinePoint {
|
|
14
|
+
location?: StorymapSlideLocation;
|
|
15
|
+
lat?: number;
|
|
16
|
+
lon?: number;
|
|
17
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* EmbedUtil
|
|
2
|
+
Utilities for safely rendering user-pasted embed snippets.
|
|
3
|
+
|
|
4
|
+
The media "url" field is overloaded: for the iframe and blockquote
|
|
5
|
+
media types it holds raw HTML pasted by the user (that markup is
|
|
6
|
+
what routes the media to those types in MediaType.js). These helpers
|
|
7
|
+
parse that HTML inertly -- DOMParser never executes scripts or event
|
|
8
|
+
handlers -- and rebuild clean DOM from an allowlist, so markup stored
|
|
9
|
+
in a storymap's JSON can never execute code in a viewer's browser.
|
|
10
|
+
================================================== */
|
|
11
|
+
|
|
12
|
+
// Attributes copied from a pasted <iframe> onto the rebuilt element.
|
|
13
|
+
// src is handled separately (validated); everything else is dropped.
|
|
14
|
+
const IFRAME_ATTRIBUTES = [
|
|
15
|
+
"width",
|
|
16
|
+
"height",
|
|
17
|
+
"frameborder",
|
|
18
|
+
"allowfullscreen",
|
|
19
|
+
"allow",
|
|
20
|
+
"scrolling",
|
|
21
|
+
"title",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Tags allowed to survive blockquote sanitization. Elements not listed
|
|
25
|
+
// are unwrapped (their children are kept); DROP_TAGS are removed along
|
|
26
|
+
// with their contents.
|
|
27
|
+
const BLOCKQUOTE_TAGS = [
|
|
28
|
+
"BLOCKQUOTE",
|
|
29
|
+
"P",
|
|
30
|
+
"CITE",
|
|
31
|
+
"EM",
|
|
32
|
+
"STRONG",
|
|
33
|
+
"B",
|
|
34
|
+
"I",
|
|
35
|
+
"U",
|
|
36
|
+
"Q",
|
|
37
|
+
"A",
|
|
38
|
+
"BR",
|
|
39
|
+
"SPAN",
|
|
40
|
+
"SMALL",
|
|
41
|
+
"SUP",
|
|
42
|
+
"SUB",
|
|
43
|
+
"FOOTER",
|
|
44
|
+
"UL",
|
|
45
|
+
"OL",
|
|
46
|
+
"LI",
|
|
47
|
+
];
|
|
48
|
+
const DROP_TAGS = [
|
|
49
|
+
"SCRIPT",
|
|
50
|
+
"STYLE",
|
|
51
|
+
"TEMPLATE",
|
|
52
|
+
"IFRAME",
|
|
53
|
+
"FRAME",
|
|
54
|
+
"OBJECT",
|
|
55
|
+
"EMBED",
|
|
56
|
+
"APPLET",
|
|
57
|
+
"LINK",
|
|
58
|
+
"META",
|
|
59
|
+
"BASE",
|
|
60
|
+
"SVG",
|
|
61
|
+
"MATH",
|
|
62
|
+
"FORM",
|
|
63
|
+
"INPUT",
|
|
64
|
+
"BUTTON",
|
|
65
|
+
"TEXTAREA",
|
|
66
|
+
"SELECT",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function parseInert(html: string): Document {
|
|
70
|
+
return new DOMParser().parseFromString(html, "text/html");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Return an absolute http(s) URL for url, or null if it is empty,
|
|
74
|
+
unparseable, or uses any other protocol (javascript:, data:, ...).
|
|
75
|
+
Relative and protocol-relative URLs resolve against the document.
|
|
76
|
+
================================================== */
|
|
77
|
+
export function validateWebURL(url: string | null): string | null {
|
|
78
|
+
if (!url) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const a = document.createElement("a");
|
|
82
|
+
a.href = url;
|
|
83
|
+
if (a.protocol === "http:" || a.protocol === "https:") {
|
|
84
|
+
return a.href;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Build a clean <iframe> element from a pasted embed snippet.
|
|
90
|
+
Only the validated src and allowlisted presentation attributes
|
|
91
|
+
survive; event handlers, extra tags and scripts are discarded.
|
|
92
|
+
Also accepts a bare URL for the src. Returns null if no safe
|
|
93
|
+
src can be extracted.
|
|
94
|
+
================================================== */
|
|
95
|
+
export function buildIframe(html: string): HTMLIFrameElement | null {
|
|
96
|
+
const pasted = parseInert(html).querySelector("iframe");
|
|
97
|
+
let src: string | null = null;
|
|
98
|
+
|
|
99
|
+
if (pasted) {
|
|
100
|
+
src = validateWebURL(pasted.getAttribute("src"));
|
|
101
|
+
} else if (/^https?:\/\/\S+$/i.test(html.trim())) {
|
|
102
|
+
// The field held a bare URL rather than an embed snippet
|
|
103
|
+
src = validateWebURL(html.trim());
|
|
104
|
+
}
|
|
105
|
+
if (!src) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const iframe = document.createElement("iframe");
|
|
110
|
+
iframe.setAttribute("src", src);
|
|
111
|
+
if (pasted) {
|
|
112
|
+
for (let i = 0; i < IFRAME_ATTRIBUTES.length; i++) {
|
|
113
|
+
if (pasted.hasAttribute(IFRAME_ATTRIBUTES[i])) {
|
|
114
|
+
iframe.setAttribute(
|
|
115
|
+
IFRAME_ATTRIBUTES[i],
|
|
116
|
+
pasted.getAttribute(IFRAME_ATTRIBUTES[i]),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
iframe.setAttribute("width", "100%");
|
|
122
|
+
iframe.setAttribute("height", "100%");
|
|
123
|
+
iframe.setAttribute("frameborder", "0");
|
|
124
|
+
iframe.setAttribute("allowfullscreen", "");
|
|
125
|
+
}
|
|
126
|
+
return iframe;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Sanitize pasted blockquote markup into a DocumentFragment.
|
|
130
|
+
Allowlisted formatting tags are kept (with all attributes stripped,
|
|
131
|
+
except a validated href on links), unknown tags are unwrapped, and
|
|
132
|
+
executable/embedding tags are dropped with their contents.
|
|
133
|
+
================================================== */
|
|
134
|
+
export function sanitizeBlockquote(html: string): DocumentFragment {
|
|
135
|
+
const fragment = document.createDocumentFragment();
|
|
136
|
+
appendSanitized(parseInert(html).body, fragment);
|
|
137
|
+
return fragment;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function appendSanitized(node: Node, parent: Node): void {
|
|
141
|
+
for (let i = 0; i < node.childNodes.length; i++) {
|
|
142
|
+
const child = node.childNodes[i];
|
|
143
|
+
if (child.nodeType === 3) {
|
|
144
|
+
parent.appendChild(document.createTextNode(child.nodeValue));
|
|
145
|
+
} else if (child.nodeType === 1) {
|
|
146
|
+
const tag = child.nodeName.toUpperCase();
|
|
147
|
+
if (DROP_TAGS.includes(tag)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (BLOCKQUOTE_TAGS.includes(tag)) {
|
|
151
|
+
const el = document.createElement(tag);
|
|
152
|
+
if (tag === "A") {
|
|
153
|
+
const href = validateWebURL((child as Element).getAttribute("href"));
|
|
154
|
+
if (href) {
|
|
155
|
+
el.setAttribute("href", href);
|
|
156
|
+
el.setAttribute("target", "_blank");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
appendSanitized(child, el);
|
|
160
|
+
parent.appendChild(el);
|
|
161
|
+
} else {
|
|
162
|
+
// Unknown tag: unwrap, keeping its children
|
|
163
|
+
appendSanitized(child, parent);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Comments and other node types are dropped
|
|
167
|
+
}
|
|
168
|
+
}
|