@schukai/monster 4.150.7 → 4.150.9

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.
@@ -29,12 +29,16 @@ import "./viewer/message.mjs";
29
29
  import { getLocaleOfDocument } from "../../dom/locale.mjs";
30
30
  import { Button } from "../form/button.mjs";
31
31
  import { findTargetElementFromEvent } from "../../dom/events.mjs";
32
- import { sanitizeHtml } from "../../dom/sanitize-html.mjs";
32
+ import { sanitizeViewerHtml } from "./viewer/sanitize-html.mjs";
33
33
 
34
34
  export { Viewer };
35
35
 
36
- const BLOCKED_HTML_RESOURCE_DATA_URL =
37
- "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
36
+ const SAFE_VIEWER_LINK_PROTOCOLS = new Set([
37
+ "http:",
38
+ "https:",
39
+ "mailto:",
40
+ "tel:",
41
+ ]);
38
42
 
39
43
  /**
40
44
  * @private
@@ -42,6 +46,24 @@ const BLOCKED_HTML_RESOURCE_DATA_URL =
42
46
  */
43
47
  const viewerElementSymbol = Symbol("viewerElement");
44
48
 
49
+ /** @private @type {symbol} */
50
+ const linkConfirmationElementSymbol = Symbol("linkConfirmationElement");
51
+
52
+ /** @private @type {symbol} */
53
+ const linkHostElementSymbol = Symbol("linkHostElement");
54
+
55
+ /** @private @type {symbol} */
56
+ const linkUrlElementSymbol = Symbol("linkUrlElement");
57
+
58
+ /** @private @type {symbol} */
59
+ const pendingLinkSymbol = Symbol("pendingLink");
60
+
61
+ /** @private @type {symbol} */
62
+ const linkClickHandlerSymbol = Symbol("linkClickHandler");
63
+
64
+ /** @private @type {symbol} */
65
+ const linkKeydownHandlerSymbol = Symbol("linkKeydownHandler");
66
+
45
67
  /**
46
68
  * @private
47
69
  * @type {symbol}
@@ -74,6 +96,16 @@ class Viewer extends CustomElement {
74
96
  return Symbol.for("@schukai/monster/components/content/viewer@@instance");
75
97
  }
76
98
 
99
+ connectedCallback() {
100
+ super.connectedCallback();
101
+ if (this[linkClickHandlerSymbol]) {
102
+ this.addEventListener("click", this[linkClickHandlerSymbol], true);
103
+ }
104
+ if (this[linkKeydownHandlerSymbol]) {
105
+ this.addEventListener("keydown", this[linkKeydownHandlerSymbol], true);
106
+ }
107
+ }
108
+
77
109
  /**
78
110
  * To set the options via the HTML tag, the attribute `data-monster-options` must be used.
79
111
  * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
@@ -127,6 +159,14 @@ class Viewer extends CustomElement {
127
159
  */
128
160
  disconnectedCallback() {
129
161
  super.disconnectedCallback?.();
162
+ if (this[linkClickHandlerSymbol]) {
163
+ this.removeEventListener("click", this[linkClickHandlerSymbol], true);
164
+ this[linkClickHandlerSymbol] = null;
165
+ }
166
+ if (this[linkKeydownHandlerSymbol]) {
167
+ this.removeEventListener("keydown", this[linkKeydownHandlerSymbol], true);
168
+ this[linkKeydownHandlerSymbol] = null;
169
+ }
130
170
  if (this[downloadHandlerSymbol]) {
131
171
  this.removeEventListener("click", this[downloadHandlerSymbol]);
132
172
  this[downloadHandlerSymbol] = null;
@@ -736,7 +776,7 @@ class Viewer extends CustomElement {
736
776
  if (data instanceof Blob) {
737
777
  blobToText(data)
738
778
  .then((html) => {
739
- this.setOption("content", sanitizeViewerHTMLContent(html));
779
+ this.setOption("content", sanitizeViewerHtml(html));
740
780
  })
741
781
  .catch((error) => {
742
782
  this.dispatchEvent(
@@ -758,7 +798,7 @@ class Viewer extends CustomElement {
758
798
  return response.text();
759
799
  })
760
800
  .then((html) => {
761
- this.setOption("content", sanitizeViewerHTMLContent(html));
801
+ this.setOption("content", sanitizeViewerHtml(html));
762
802
  })
763
803
  .catch((error) => {
764
804
  this.dispatchEvent(
@@ -775,7 +815,7 @@ class Viewer extends CustomElement {
775
815
  throw new Error("HTMLElement or string expected");
776
816
  }
777
817
 
778
- this.setOption("content", sanitizeViewerHTMLContent(data));
818
+ this.setOption("content", sanitizeViewerHtml(data));
779
819
  }
780
820
 
781
821
  /**
@@ -904,217 +944,203 @@ function blobToText(blob) {
904
944
  });
905
945
  }
906
946
 
907
- function sanitizeViewerHTMLContent(html) {
908
- const sanitizedHTML = sanitizeHtml(html);
909
- const parser = new DOMParser();
910
- const doc = parser.parseFromString(sanitizedHTML, "text/html");
947
+ /**
948
+ * @private
949
+ * @return {Select}
950
+ * @throws {Error} no shadow-root is defined
951
+ */
952
+ function initControlReferences() {
953
+ if (!this.shadowRoot) {
954
+ throw new Error("no shadow-root is defined");
955
+ }
956
+
957
+ this[viewerElementSymbol] = this.shadowRoot.getElementById("viewer");
958
+ this[linkConfirmationElementSymbol] = this.shadowRoot.querySelector(
959
+ '[data-monster-role="external-link-confirmation"]',
960
+ );
961
+ this[linkHostElementSymbol] = this.shadowRoot.querySelector(
962
+ '[data-monster-role="external-link-host"]',
963
+ );
964
+ this[linkUrlElementSymbol] = this.shadowRoot.querySelector(
965
+ '[data-monster-role="external-link-url"]',
966
+ );
967
+ }
911
968
 
912
- doc.querySelectorAll("*").forEach((element) => {
913
- removeCrossOriginResourceAttribute(element, "src");
914
- removeCrossOriginResourceAttribute(element, "srcset");
915
- removeCrossOriginResourceAttribute(element, "poster");
969
+ /**
970
+ * @private
971
+ */
972
+ function initEventHandler() {
973
+ this[linkClickHandlerSymbol] = (event) => {
974
+ const path = event.composedPath?.() || [];
975
+ const actionElement = path.find(
976
+ (element) =>
977
+ element instanceof Element &&
978
+ element.hasAttribute?.("data-monster-link-action"),
979
+ );
916
980
 
917
- if (element.hasAttribute("style")) {
918
- const safeStyle = sanitizeStyleAttribute(element.getAttribute("style"));
919
- if (safeStyle) {
920
- element.setAttribute("style", safeStyle);
921
- } else {
922
- element.removeAttribute("style");
923
- }
981
+ if (actionElement) {
982
+ event.preventDefault();
983
+ event.stopPropagation();
984
+ handleLinkConfirmationAction.call(
985
+ this,
986
+ actionElement.getAttribute("data-monster-link-action"),
987
+ );
988
+ return;
924
989
  }
925
- });
926
990
 
927
- return doc.body.innerHTML;
928
- }
991
+ const link = path.find(
992
+ (element) => element instanceof Element && element.localName === "a",
993
+ );
994
+ if (!link) {
995
+ return;
996
+ }
929
997
 
930
- function removeCrossOriginResourceAttribute(element, attributeName) {
931
- const value = element.getAttribute(attributeName);
932
- if (!value) {
933
- return;
934
- }
998
+ const href = link.getAttribute("href")?.trim();
999
+ if (!href || href.startsWith("#")) {
1000
+ return;
1001
+ }
935
1002
 
936
- if (attributeName === "srcset") {
937
- const sameOriginEntries = value
938
- .split(",")
939
- .map((entry) => entry.trim())
940
- .filter(Boolean)
941
- .filter((entry) => {
942
- const [candidateUrl] = entry.split(/\s+/, 1);
943
- return isSameOriginResourceUrl(candidateUrl);
944
- });
945
-
946
- if (sameOriginEntries.length > 0) {
947
- element.setAttribute(attributeName, sameOriginEntries.join(", "));
1003
+ let url;
1004
+ try {
1005
+ url = new URL(href, document.baseURI);
1006
+ } catch {
1007
+ event.preventDefault();
948
1008
  return;
949
1009
  }
950
- } else if (isSameOriginResourceUrl(value)) {
951
- return;
952
- }
1010
+ if (!SAFE_VIEWER_LINK_PROTOCOLS.has(url.protocol.toLowerCase())) {
1011
+ event.preventDefault();
1012
+ return;
1013
+ }
1014
+
1015
+ event.preventDefault();
1016
+ event.stopPropagation();
1017
+ showLinkConfirmation.call(this, link, url);
1018
+ };
953
1019
 
954
- if (
955
- attributeName === "src" &&
956
- element.tagName.toLowerCase() === "img" &&
957
- !element.classList.contains("privacyImage")
958
- ) {
959
- element.setAttribute("src", BLOCKED_HTML_RESOURCE_DATA_URL);
960
- element.classList.add("privacyImage");
1020
+ this[linkKeydownHandlerSymbol] = (event) => {
1021
+ if (event.key !== "Escape" || this[linkConfirmationElementSymbol]?.hidden) {
1022
+ return;
1023
+ }
1024
+ event.preventDefault();
1025
+ closeLinkConfirmation.call(this, true);
1026
+ };
1027
+
1028
+ this.addEventListener("click", this[linkClickHandlerSymbol], true);
1029
+ this.addEventListener("keydown", this[linkKeydownHandlerSymbol], true);
1030
+ return this;
1031
+ }
1032
+
1033
+ function showLinkConfirmation(link, url) {
1034
+ const confirmation = this[linkConfirmationElementSymbol];
1035
+ if (!confirmation) {
961
1036
  return;
962
1037
  }
963
1038
 
964
- element.removeAttribute(attributeName);
965
- }
1039
+ this[pendingLinkSymbol] = { link, url };
1040
+ this[linkHostElementSymbol].textContent = url.hostname || url.protocol;
1041
+ this[linkUrlElementSymbol].textContent = url.href;
1042
+ confirmation.hidden = false;
966
1043
 
967
- function sanitizeStyleAttribute(styleValue) {
968
- return styleValue.replace(/url\(([^)]+)\)/gi, (match, rawUrl) => {
969
- const cleanedUrl = rawUrl.trim().replace(/^['"]|['"]$/g, "");
970
- return isSameOriginResourceUrl(cleanedUrl) ? match : "";
1044
+ queueMicrotask(() => {
1045
+ positionLinkConfirmation(confirmation, link);
1046
+ confirmation.querySelector('[data-monster-link-action="cancel"]')?.focus();
971
1047
  });
972
1048
  }
973
1049
 
974
- function isSameOriginResourceUrl(value) {
975
- if (!value) {
976
- return true;
977
- }
978
-
979
- const trimmedValue = value.trim();
980
- if (!trimmedValue || trimmedValue.startsWith("#")) {
981
- return true;
982
- }
1050
+ function positionLinkConfirmation(confirmation, link) {
1051
+ const linkRect = link.getBoundingClientRect();
1052
+ const width = confirmation.offsetWidth || 360;
1053
+ const height = confirmation.offsetHeight || 220;
1054
+ const viewportWidth =
1055
+ document.documentElement.clientWidth || window.innerWidth;
1056
+ const viewportHeight =
1057
+ document.documentElement.clientHeight || window.innerHeight;
1058
+ const edge = 16;
1059
+ const gap = 8;
1060
+ const left = Math.max(
1061
+ edge,
1062
+ Math.min(linkRect.left, viewportWidth - width - edge),
1063
+ );
1064
+ const below = linkRect.bottom + gap;
1065
+ const top =
1066
+ below + height <= viewportHeight - edge
1067
+ ? below
1068
+ : Math.max(edge, linkRect.top - height - gap);
1069
+
1070
+ confirmation.style.left = `${left}px`;
1071
+ confirmation.style.top = `${top}px`;
1072
+ }
983
1073
 
984
- try {
985
- const url = new URL(trimmedValue, document.location.href);
986
- return (
987
- url.origin === document.location.origin ||
988
- url.protocol === "data:" ||
989
- url.protocol === "blob:"
990
- );
991
- } catch {
992
- return true;
1074
+ function handleLinkConfirmationAction(action) {
1075
+ if (action === "cancel") {
1076
+ closeLinkConfirmation.call(this, true);
1077
+ return;
993
1078
  }
994
- }
995
1079
 
996
- /**
997
- * @private
998
- * @return {Select}
999
- * @throws {Error} no shadow-root is defined
1000
- */
1001
- function initControlReferences() {
1002
- if (!this.shadowRoot) {
1003
- throw new Error("no shadow-root is defined");
1080
+ const pendingLink = this[pendingLinkSymbol];
1081
+ if (!pendingLink || (action !== "open" && action !== "new-window")) {
1082
+ return;
1004
1083
  }
1005
1084
 
1006
- this[viewerElementSymbol] = this.shadowRoot.getElementById("viewer");
1085
+ const target = action === "new-window" ? "_blank" : "_self";
1086
+ window.open(pendingLink.url.href, target, "noopener,noreferrer");
1087
+ closeLinkConfirmation.call(this, false);
1007
1088
  }
1008
1089
 
1009
- /**
1010
- * @private
1011
- */
1012
- function initEventHandler() {
1013
- return this;
1090
+ function closeLinkConfirmation(restoreFocus) {
1091
+ const pendingLink = this[pendingLinkSymbol];
1092
+ this[linkConfirmationElementSymbol].hidden = true;
1093
+ delete this[pendingLinkSymbol];
1094
+ if (restoreFocus) {
1095
+ pendingLink?.link?.focus();
1096
+ }
1014
1097
  }
1015
1098
 
1016
1099
  function getLabels() {
1017
- switch (getLocaleOfDocument().language) {
1018
- case "de": // German
1019
- return {
1020
- download: "Herunterladen",
1021
- };
1022
-
1023
- case "es": // Spanish
1024
- return {
1025
- download: "Descargar",
1026
- };
1027
-
1028
- case "zh": // Mandarin
1029
- return {
1030
- download: "下载",
1031
- };
1032
-
1033
- case "hi": // Hindi
1034
- return {
1035
- download: "डाउनलोड करें",
1036
- };
1037
-
1038
- case "bn": // Bengali
1039
- return {
1040
- download: "ডাউনলোড",
1041
- };
1042
-
1043
- case "pt": // Portuguese
1044
- return {
1045
- download: "Baixar",
1046
- };
1047
-
1048
- case "ru": // Russian
1049
- return {
1050
- download: "Скачать",
1051
- };
1052
-
1053
- case "ja": // Japanese
1054
- return {
1055
- download: "ダウンロード",
1056
- };
1057
-
1058
- case "pa": // Western Punjabi
1059
- return {
1060
- download: "ਡਾਊਨਲੋਡ ਕਰੋ",
1061
- };
1062
-
1063
- case "mr": // Marathi
1064
- return {
1065
- download: "डाउनलोड करा",
1066
- };
1067
-
1068
- case "fr": // French
1069
- return {
1070
- download: "Télécharger",
1071
- };
1072
-
1073
- case "it": // Italian
1074
- return {
1075
- download: "Scarica",
1076
- };
1077
-
1078
- case "nl": // Dutch
1079
- return {
1080
- download: "Downloaden",
1081
- };
1082
-
1083
- case "sv": // Swedish
1084
- return {
1085
- download: "Ladda ner",
1086
- };
1087
-
1088
- case "pl": // Polish
1089
- return {
1090
- download: "Pobierz",
1091
- };
1092
-
1093
- case "da": // Danish
1094
- return {
1095
- download: "Hent",
1096
- };
1097
-
1098
- case "fi": // Finnish
1099
- return {
1100
- download: "Lataa",
1101
- };
1102
-
1103
- case "no": // Norwegian
1104
- return {
1105
- download: "Last ned",
1106
- };
1107
-
1108
- case "cs": // Czech
1109
- return {
1110
- download: "Stáhnout",
1111
- };
1112
-
1113
- default:
1114
- return {
1115
- download: "Download",
1116
- };
1117
- }
1100
+ const language = getLocaleOfDocument().language;
1101
+ const downloadLabels = {
1102
+ en: "Download",
1103
+ de: "Herunterladen",
1104
+ es: "Descargar",
1105
+ zh: "下载",
1106
+ hi: "डाउनलोड करें",
1107
+ bn: "ডাউনলোড",
1108
+ pt: "Baixar",
1109
+ ru: "Скачать",
1110
+ ja: "ダウンロード",
1111
+ pa: "ਡਾਊਨਲੋਡ ਕਰੋ",
1112
+ mr: "डाउनलोड करा",
1113
+ fr: "Télécharger",
1114
+ it: "Scarica",
1115
+ nl: "Downloaden",
1116
+ sv: "Ladda ner",
1117
+ pl: "Pobierz",
1118
+ da: "Hent",
1119
+ fi: "Lataa",
1120
+ no: "Last ned",
1121
+ cs: "Stáhnout",
1122
+ };
1123
+ const linkLabels =
1124
+ language === "de"
1125
+ ? {
1126
+ externalLinkTitle: "Externen Link öffnen?",
1127
+ externalLinkMessage: "Dieser Link führt zu:",
1128
+ externalLinkCancel: "Abbrechen",
1129
+ externalLinkOpen: "Öffnen",
1130
+ externalLinkNewWindow: "In neuem Fenster öffnen",
1131
+ }
1132
+ : {
1133
+ externalLinkTitle: "Open external link?",
1134
+ externalLinkMessage: "This link leads to:",
1135
+ externalLinkCancel: "Cancel",
1136
+ externalLinkOpen: "Open",
1137
+ externalLinkNewWindow: "Open in new window",
1138
+ };
1139
+
1140
+ return {
1141
+ download: downloadLabels[language] || "Download",
1142
+ ...linkLabels,
1143
+ };
1118
1144
  }
1119
1145
 
1120
1146
  /**
@@ -1127,7 +1153,26 @@ function getTemplate() {
1127
1153
  <div id="viewer" data-monster-role="viewer" part="viewer"
1128
1154
  data-monster-replace="path:content"
1129
1155
  data-monster-attributes="class path:classes.viewer">
1130
- </div>`;
1156
+ </div>
1157
+ <section data-monster-role="external-link-confirmation" part="external-link-confirmation"
1158
+ role="dialog" aria-labelledby="external-link-title"
1159
+ aria-describedby="external-link-message external-link-url" hidden>
1160
+ <strong id="external-link-title" data-monster-replace="path:labels.externalLinkTitle"></strong>
1161
+ <p id="external-link-message" data-monster-replace="path:labels.externalLinkMessage"></p>
1162
+ <strong data-monster-role="external-link-host"></strong>
1163
+ <code id="external-link-url" data-monster-role="external-link-url"></code>
1164
+ <div data-monster-role="external-link-actions">
1165
+ <button type="button" class="monster-button-outline-secondary"
1166
+ data-monster-link-action="cancel"
1167
+ data-monster-replace="path:labels.externalLinkCancel"></button>
1168
+ <button type="button" class="monster-button-primary"
1169
+ data-monster-link-action="open"
1170
+ data-monster-replace="path:labels.externalLinkOpen"></button>
1171
+ <button type="button" class="monster-button-outline-primary"
1172
+ data-monster-link-action="new-window"
1173
+ data-monster-replace="path:labels.externalLinkNewWindow"></button>
1174
+ </div>
1175
+ </section>`;
1131
1176
  }
1132
1177
 
1133
1178
  registerCustomElement(Viewer);