diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-21 11:44:51 +0000 |
commit | 9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /browser/extensions/screenshots/build | |
parent | Initial commit. (diff) | |
download | thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.tar.xz thunderbird-9e3c08db40b8916968b9f30096c7be3f00ce9647.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/extensions/screenshots/build')
-rw-r--r-- | browser/extensions/screenshots/build/inlineSelectionCss.js | 667 | ||||
-rw-r--r-- | browser/extensions/screenshots/build/selection.js | 126 | ||||
-rw-r--r-- | browser/extensions/screenshots/build/shot.js | 888 | ||||
-rw-r--r-- | browser/extensions/screenshots/build/thumbnailGenerator.js | 190 |
4 files changed, 1871 insertions, 0 deletions
diff --git a/browser/extensions/screenshots/build/inlineSelectionCss.js b/browser/extensions/screenshots/build/inlineSelectionCss.js new file mode 100644 index 0000000000..fa31b642df --- /dev/null +++ b/browser/extensions/screenshots/build/inlineSelectionCss.js @@ -0,0 +1,667 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* Created from build/server/static/css/inline-selection.css */ +window.inlineSelectionCss = ` +.button, .highlight-button-cancel, .highlight-button-download, .highlight-button-copy { + display: flex; + align-items: center; + justify-content: center; + column-gap: 8px; + border: 0; + border-radius: 3px; + cursor: pointer; + font-size: 16px; + font-weight: 400; + height: 40px; + min-width: 40px; + outline: none; + padding: 0 10px; + position: relative; + text-align: center; + text-decoration: none; + transition: background 150ms cubic-bezier(0.07, 0.95, 0, 1), border 150ms cubic-bezier(0.07, 0.95, 0, 1); + user-select: none; + white-space: nowrap; } + .button.hidden, .hidden.highlight-button-cancel, .hidden.highlight-button-download, .hidden.highlight-button-copy { + display: none; } + .button.small, .small.highlight-button-cancel, .small.highlight-button-download, .small.highlight-button-copy { + height: 32px; + line-height: 32px; + padding: 0 8px; } + .button.active, .active.highlight-button-cancel, .active.highlight-button-download, .active.highlight-button-copy { + background-color: #dedede; } + .button.tiny, .tiny.highlight-button-cancel, .tiny.highlight-button-download, .tiny.highlight-button-copy { + font-size: 14px; + height: 26px; + border: 1px solid #c7c7c7; } + .button.tiny:hover, .tiny.highlight-button-cancel:hover, .tiny.highlight-button-download:hover, .tiny.highlight-button-copy:hover, .button.tiny:focus, .tiny.highlight-button-cancel:focus, .tiny.highlight-button-download:focus, .tiny.highlight-button-copy:focus { + background: #ededf0; + border-color: #989898; } + .button.tiny:active, .tiny.highlight-button-cancel:active, .tiny.highlight-button-download:active, .tiny.highlight-button-copy:active { + background: #dedede; + border-color: #989898; } + .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: 0; + border-inline-end: 1px solid #c7c7c7; + box-shadow: 0; + border-radius: 0; + flex-shrink: 0; + font-size: 20px; + height: 100px; + line-height: 100%; + overflow: hidden; } + @media (max-width: 719px) { + .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy { + justify-content: flex-start; + font-size: 16px; + height: 72px; + margin-inline-end: 10px; + padding: 0 5px; } } + .button.block-button:hover, .block-button.highlight-button-cancel:hover, .block-button.highlight-button-download:hover, .block-button.highlight-button-copy:hover { + background: #ededf0; } + .button.block-button:active, .block-button.highlight-button-cancel:active, .block-button.highlight-button-download:active, .block-button.highlight-button-copy:active { + background: #dedede; } + .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy, .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy, .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy, .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy, .button.flag, .flag.highlight-button-cancel, .flag.highlight-button-download, .flag.highlight-button-copy { + background-repeat: no-repeat; + background-size: 50%; + background-position: center; + margin-inline-end: 10px; + transition: background-color 150ms cubic-bezier(0.07, 0.95, 0, 1); } + .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy { + background-image: url("chrome://browser/content/screenshots/download.svg"); } + .button.download:hover, .download.highlight-button-cancel:hover, .download.highlight-button-download:hover, .download.highlight-button-copy:hover { + background-color: #ededf0; } + .button.download:active, .download.highlight-button-cancel:active, .download.highlight-button-download:active, .download.highlight-button-copy:active { + background-color: #dedede; } + .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy { + background-image: url("../img/icon-share.svg"); } + .button.share:hover, .share.highlight-button-cancel:hover, .share.highlight-button-download:hover, .share.highlight-button-copy:hover { + background-color: #ededf0; } + .button.share.active, .share.active.highlight-button-cancel, .share.active.highlight-button-download, .share.active.highlight-button-copy, .button.share:active, .share.highlight-button-cancel:active, .share.highlight-button-download:active, .share.highlight-button-copy:active { + background-color: #dedede; } + .button.share.newicon, .share.newicon.highlight-button-cancel, .share.newicon.highlight-button-download, .share.newicon.highlight-button-copy { + background-image: url("../img/icon-share-alternate.svg"); } + .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy { + background-image: url("../img/icon-trash.svg"); } + .button.trash:hover, .trash.highlight-button-cancel:hover, .trash.highlight-button-download:hover, .trash.highlight-button-copy:hover { + background-color: #ededf0; } + .button.trash:active, .trash.highlight-button-cancel:active, .trash.highlight-button-download:active, .trash.highlight-button-copy:active { + background-color: #dedede; } + .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy { + background-image: url("../img/icon-edit.svg"); } + .button.edit:hover, .edit.highlight-button-cancel:hover, .edit.highlight-button-download:hover, .edit.highlight-button-copy:hover { + background-color: #ededf0; } + .button.edit:active, .edit.highlight-button-cancel:active, .edit.highlight-button-download:active, .edit.highlight-button-copy:active { + background-color: #dedede; } + +.app-body { + background: #f9f9fa; + color: #38383d; } + .app-body a { + color: #0a84ff; } + +.highlight-color-scheme { + background: #0a84ff; + color: #fff; } + .highlight-color-scheme a { + color: #fff; + text-decoration: underline; } + +.alt-color-scheme { + background: #38383d; + color: #f9f9fa; } + .alt-color-scheme h1 { + color: #6f7fb6; } + .alt-color-scheme a { + color: #e1e1e6; + text-decoration: underline; } + +.button.primary, .primary.highlight-button-cancel, .highlight-button-download, .primary.highlight-button-copy { + background-color: #0a84ff; + color: #fff; } + .button.primary:hover, .primary.highlight-button-cancel:hover, .highlight-button-download:hover, .primary.highlight-button-copy:hover, .button.primary:focus, .primary.highlight-button-cancel:focus, .highlight-button-download:focus, .primary.highlight-button-copy:focus { + background-color: #0072e5; } + .button.primary:active, .primary.highlight-button-cancel:active, .highlight-button-download:active, .primary.highlight-button-copy:active { + background-color: #0065cc; } + +.button.secondary, .highlight-button-cancel, .secondary.highlight-button-download, .highlight-button-copy { + background-color: #f9f9fa; + color: #38383d; } + .button.secondary:hover, .highlight-button-cancel:hover, .secondary.highlight-button-download:hover, .highlight-button-copy:hover { + background-color: #ededf0; } + .button.secondary:active, .highlight-button-cancel:active, .secondary.highlight-button-download:active, .highlight-button-copy:active { + background-color: #dedede; } + +.button.transparent, .transparent.highlight-button-cancel, .transparent.highlight-button-download, .transparent.highlight-button-copy { + background-color: transparent; + color: #38383d; } + .button.transparent:hover, .transparent.highlight-button-cancel:hover, .transparent.highlight-button-download:hover, .transparent.highlight-button-copy:hover { + background-color: #ededf0; } + .button.transparent:focus, .transparent.highlight-button-cancel:focus, .transparent.highlight-button-download:focus, .transparent.highlight-button-copy:focus, .button.transparent:active, .transparent.highlight-button-cancel:active, .transparent.highlight-button-download:active, .transparent.highlight-button-copy:active { + background-color: #dedede; } + +.button.warning, .warning.highlight-button-cancel, .warning.highlight-button-download, .warning.highlight-button-copy { + color: #fff; + background: #d92215; } + .button.warning:hover, .warning.highlight-button-cancel:hover, .warning.highlight-button-download:hover, .warning.highlight-button-copy:hover, .button.warning:focus, .warning.highlight-button-cancel:focus, .warning.highlight-button-download:focus, .warning.highlight-button-copy:focus { + background: #b81d12; } + .button.warning:active, .warning.highlight-button-cancel:active, .warning.highlight-button-download:active, .warning.highlight-button-copy:active { + background: #a11910; } + +.subtitle-link { + color: #0a84ff; } + +.loader { + background: rgba(12, 12, 13, 0.2); + border-radius: 2px; + height: 4px; + overflow: hidden; + position: relative; + width: 200px; } + +.loader-inner { + animation: bounce infinite alternate 1250ms cubic-bezier(0.7, 0, 0.3, 1); + background: #45a1ff; + border-radius: 2px; + height: 4px; + transform: translateX(-40px); + width: 50px; } + +@keyframes bounce { + 0% { + transform: translateX(-40px); } + 100% { + transform: translate(190px); } } + +@keyframes fade-in { + 0% { + opacity: 0; } + 100% { + opacity: 1; } } + +@keyframes pop { + 0% { + transform: scale(1); } + 97% { + transform: scale(1.04); } + 100% { + transform: scale(1); } } + +@keyframes pulse { + 0% { + opacity: 0.3; + transform: scale(1); } + 70% { + opacity: 0.25; + transform: scale(1.04); } + 100% { + opacity: 0.3; + transform: scale(1); } } + +@keyframes slide-left { + 0% { + opacity: 0; + transform: translate3d(160px, 0, 0); } + 100% { + opacity: 1; + transform: translate3d(0, 0, 0); } } + +@keyframes bounce-in { + 0% { + opacity: 0; + transform: scale(1); } + 60% { + opacity: 1; + transform: scale(1.02); } + 100% { + transform: scale(1); } } + +.mover-target { + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; + position: absolute; + z-index: 5; } + +.highlight, +.mover-target { + background-color: transparent; + background-image: none; } + +.mover-target, +.bghighlight { + border: 0; } + +.hover-highlight { + animation: fade-in 125ms forwards cubic-bezier(0.07, 0.95, 0, 1); + background: rgba(255, 255, 255, 0.2); + border-radius: 1px; + pointer-events: none; + position: absolute; + z-index: 10000000000; } + .hover-highlight::before { + border: 2px dashed rgba(255, 255, 255, 0.4); + bottom: 0; + content: ""; + inset-inline-start: 0; + position: absolute; + inset-inline-end: 0; + top: 0; } + /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */ + @media (forced-colors: active) { + .hover-highlight { + background-color: white; + opacity: 0.2; } } + +.mover-target.direction-topLeft { + cursor: nwse-resize; + height: 60px; + left: -30px; + top: -30px; + width: 60px; } + +.mover-target.direction-top { + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + top: -30px; + width: 100%; + z-index: 4; } + +.mover-target.direction-topRight { + cursor: nesw-resize; + height: 60px; + right: -30px; + top: -30px; + width: 60px; } + +.mover-target.direction-left { + cursor: ew-resize; + height: 100%; + left: -30px; + top: 0; + width: 60px; + z-index: 4; } + +.mover-target.direction-right { + cursor: ew-resize; + height: 100%; + right: -30px; + top: 0; + width: 60px; + z-index: 4; } + +.mover-target.direction-bottomLeft { + bottom: -30px; + cursor: nesw-resize; + height: 60px; + left: -30px; + width: 60px; } + +.mover-target.direction-bottom { + bottom: -30px; + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + width: 100%; + z-index: 4; } + +.mover-target.direction-bottomRight { + bottom: -30px; + cursor: nwse-resize; + height: 60px; + right: -30px; + width: 60px; } + +.mover-target:hover .mover { + transform: scale(1.05); } + +.mover { + background-color: #fff; + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); + height: 16px; + opacity: 1; + position: relative; + transition: transform 125ms cubic-bezier(0.07, 0.95, 0, 1); + width: 16px; } + .small-selection .mover { + height: 10px; + width: 10px; } + +.direction-topLeft .mover, +.direction-left .mover, +.direction-bottomLeft .mover { + left: -1px; } + +.direction-topLeft .mover, +.direction-top .mover, +.direction-topRight .mover { + top: -1px; } + +.direction-topRight .mover, +.direction-right .mover, +.direction-bottomRight .mover { + right: -1px; } + +.direction-bottomRight .mover, +.direction-bottom .mover, +.direction-bottomLeft .mover { + bottom: -1px; } + +.bghighlight { + background-color: rgba(0, 0, 0, 0.7); + position: absolute; + z-index: 9999999999; } + /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */ + @media (forced-colors: active) { + .bghighlight { + background-color: black; + opacity: 0.7; } } + +.preview-overlay { + align-items: center; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + height: 100%; + justify-content: center; + inset-inline-start: 0; + margin: 0; + padding: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 9999999999; } + /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */ + @media (forced-colors: active) { + .preview-overlay { + background-color: black; + opacity: 0.7; } } + +.precision-cursor { + cursor: crosshair; } + +.highlight { + border-radius: 1px; + border: 2px dashed rgba(255, 255, 255, 0.8); + box-sizing: border-box; + cursor: move; + position: absolute; + z-index: 9999999999; } + /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */ + @media (forced-colors: active) { + .highlight { + border: 2px dashed white; + opacity: 1.0; } } + +.highlight-buttons { + display: flex; + align-items: center; + justify-content: center; + bottom: -58px; + position: absolute; + inset-inline-end: 5px; + z-index: 6; } + .bottom-selection .highlight-buttons { + bottom: 5px; } + .left-selection .highlight-buttons { + inset-inline-end: auto; + inset-inline-start: 5px; } + .highlight-buttons > button { + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); } + +.highlight-button-cancel { + margin: 5px; + width: 40px; } + +.highlight-button-download { + margin: 5px; + width: auto; + font-size: 18px; } + +.highlight-button-download img { + height: 16px; + width: 16px; +} + +.highlight-button-download:-moz-locale-dir(rtl) { + flex-direction: reverse; +} + +.highlight-button-download img:-moz-locale-dir(ltr) { + padding-inline-end: 8px; +} + +.highlight-button-download img:-moz-locale-dir(rtl) { + padding-inline-start: 8px; +} + +.highlight-button-copy { + margin: 5px; + width: auto; } + +.highlight-button-copy img { + height: 16px; + width: 16px; +} + +.highlight-button-copy:-moz-locale-dir(rtl) { + flex-direction: reverse; +} + +.highlight-button-copy img:-moz-locale-dir(ltr) { + padding-inline-end: 8px; +} + +.highlight-button-copy img:-moz-locale-dir(rtl) { + padding-inline-start: 8px; +} + +.pixel-dimensions { + position: absolute; + pointer-events: none; + font-weight: bold; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 70%; + color: #000; + text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; } + +.preview-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + padding-inline-end: 4px; + inset-inline-end: 0; + width: 100%; + position: absolute; + height: 60px; + border-radius: 4px 4px 0 0; + background: rgba(249, 249, 250, 0.8); + top: 0; + border: 1px solid rgba(249, 249, 250, 0.2); + border-bottom: 0; + box-sizing: border-box; } + +.preview-image { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + margin: 24px auto; + position: relative; + max-width: 80%; + max-height: 95%; + text-align: center; + animation-delay: 50ms; + display: flex; } + +.preview-image-wrapper { + background: rgba(249, 249, 250, 0.8); + border-radius: 0 0 4px 4px; + display: block; + height: auto; + max-width: 100%; + min-width: 320px; + overflow-y: scroll; + padding: 0 60px; + margin-top: 60px; + border: 1px solid rgba(249, 249, 250, 0.2); + border-top: 0; } + +.preview-image-wrapper > img { + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); + height: auto; + margin-bottom: 60px; + max-width: 100%; + width: 100%; } + +.fixed-container { + align-items: center; + display: flex; + flex-direction: column; + height: 100vh; + justify-content: center; + inset-inline-start: 0; + margin: 0; + padding: 0; + pointer-events: none; + position: fixed; + top: 0; + width: 100%; } + +.face-container { + position: relative; + width: 64px; + height: 64px; } + +.face { + width: 62.4px; + height: 62.4px; + display: block; + background-image: url("chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg"); } + +.eye { + background-color: #fff; + width: 10.8px; + height: 14.6px; + position: absolute; + border-radius: 100%; + overflow: hidden; + inset-inline-start: 16.4px; + top: 19.8px; } + +.eyeball { + position: absolute; + width: 6px; + height: 6px; + background-color: #000; + border-radius: 50%; + inset-inline-start: 2.4px; + top: 4.3px; + z-index: 10; } + +.left { + margin-inline-start: 0; } + +.right { + margin-inline-start: 20px; } + +.preview-instructions { + display: flex; + align-items: center; + justify-content: center; + animation: pulse 125mm cubic-bezier(0.07, 0.95, 0, 1); + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 24px; + line-height: 32px; + text-align: center; + padding-top: 20px; + width: 400px; + user-select: none; } + +.cancel-shot { + background-color: transparent; + cursor: pointer; + outline: none; + border-radius: 3px; + border: 1px #9b9b9b solid; + color: #fff; + cursor: pointer; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 16px; + margin-top: 40px; + padding: 10px 25px; + pointer-events: all; } + +.all-buttons-container { + display: flex; + flex-direction: row-reverse; + background: #f5f5f5; + border-radius: 2px; + box-sizing: border-box; + height: 80px; + padding: 8px; + position: absolute; + inset-inline-end: 8px; + top: 8px; + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); } + .all-buttons-container .spacer { + background-color: #c9c9c9; + flex: 0 0 1px; + height: 80px; + margin: 0 10px; + position: relative; + top: -8px; } + .all-buttons-container button { + display: flex; + align-items: center; + flex-direction: column; + justify-content: flex-end; + color: #3e3d40; + background-color: #f5f5f5; + background-position: center top; + background-repeat: no-repeat; + background-size: 46px 46px; + border: 1px solid transparent; + cursor: pointer; + height: 100%; + min-width: 90px; + padding: 46px 5px 5px; + pointer-events: all; + transition: border 150ms cubic-bezier(0.07, 0.95, 0, 1), background-color 150ms cubic-bezier(0.07, 0.95, 0, 1); + white-space: nowrap; } + .all-buttons-container button:hover { + background-color: #ebebeb; + border: 1px solid #c7c7c7; } + .all-buttons-container button:active { + background-color: #dedede; + border: 1px solid #989898; } + .all-buttons-container .full-page { + background-image: url("chrome://browser/content/screenshots/menu-fullpage.svg"); } + .all-buttons-container .visible { + background-image: url("chrome://browser/content/screenshots/menu-visible.svg"); } + +@keyframes pulse { + 0% { + transform: scale(1); } + 50% { + transform: scale(1.06); } + 100% { + transform: scale(1); } } + +@keyframes fade-in { + 0% { + opacity: 0; } + 100% { + opacity: 1; } } + +`; +null; diff --git a/browser/extensions/screenshots/build/selection.js b/browser/extensions/screenshots/build/selection.js new file mode 100644 index 0000000000..db93dce72b --- /dev/null +++ b/browser/extensions/screenshots/build/selection.js @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.selection = (function () { + let exports = {}; + class Selection { + constructor(x1, y1, x2, y2) { + this.x1 = x1; + this.y1 = y1; + this.x2 = x2; + this.y2 = y2; + } + + get top() { + return Math.min(this.y1, this.y2); + } + set top(val) { + if (this.y1 < this.y2) { + this.y1 = val; + } else { + this.y2 = val; + } + } + + get bottom() { + return Math.max(this.y1, this.y2); + } + set bottom(val) { + if (this.y1 > this.y2) { + this.y1 = val; + } else { + this.y2 = val; + } + } + + get left() { + return Math.min(this.x1, this.x2); + } + set left(val) { + if (this.x1 < this.x2) { + this.x1 = val; + } else { + this.x2 = val; + } + } + + get right() { + return Math.max(this.x1, this.x2); + } + set right(val) { + if (this.x1 > this.x2) { + this.x1 = val; + } else { + this.x2 = val; + } + } + + get width() { + return Math.abs(this.x2 - this.x1); + } + get height() { + return Math.abs(this.y2 - this.y1); + } + + rect() { + return { + top: Math.floor(this.top), + left: Math.floor(this.left), + bottom: Math.floor(this.bottom), + right: Math.floor(this.right), + }; + } + + union(other) { + return new Selection( + Math.min(this.left, other.left), + Math.min(this.top, other.top), + Math.max(this.right, other.right), + Math.max(this.bottom, other.bottom) + ); + } + + /** Sort x1/x2 and y1/y2 so x1<x2, y1<y2 */ + sortCoords() { + if (this.x1 > this.x2) { + [this.x1, this.x2] = [this.x2, this.x1]; + } + if (this.y1 > this.y2) { + [this.y1, this.y2] = [this.y2, this.y1]; + } + } + + clone() { + return new Selection(this.x1, this.y1, this.x2, this.y2); + } + + toJSON() { + return { + left: this.left, + right: this.right, + top: this.top, + bottom: this.bottom, + }; + } + + static getBoundingClientRect(el) { + if (!el.getBoundingClientRect) { + // Typically the <html> element or somesuch + return null; + } + const rect = el.getBoundingClientRect(); + if (!rect) { + return null; + } + return new Selection(rect.left, rect.top, rect.right, rect.bottom); + } + } + + if (typeof exports !== "undefined") { + exports.Selection = Selection; + } + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/build/shot.js b/browser/extensions/screenshots/build/shot.js new file mode 100644 index 0000000000..7153562de3 --- /dev/null +++ b/browser/extensions/screenshots/build/shot.js @@ -0,0 +1,888 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals process, require */ + +this.shot = (function () { + let exports = {}; // Note: in this library we can't use any "system" dependencies because this can be used from multiple + // environments + + const isNode = + typeof process !== "undefined" && + Object.prototype.toString.call(process) === "[object process]"; + const URL = (isNode && require("url").URL) || window.URL; + + /** Throws an error if the condition isn't true. Any extra arguments after the condition + are used as console.error() arguments. */ + function assert(condition, ...args) { + if (condition) { + return; + } + console.error("Failed assertion", ...args); + throw new Error(`Failed assertion: ${args.join(" ")}`); + } + + /** True if `url` is a valid URL */ + function isUrl(url) { + try { + const parsed = new URL(url); + + if (parsed.protocol === "view-source:") { + return isUrl(url.substr("view-source:".length)); + } + + return true; + } catch (e) { + return false; + } + } + + function isValidClipImageUrl(url) { + return isUrl(url) && !(url.indexOf(")") > -1); + } + + function assertUrl(url) { + if (!url) { + throw new Error("Empty value is not URL"); + } + if (!isUrl(url)) { + const exc = new Error("Not a URL"); + exc.scheme = url.split(":")[0]; + throw exc; + } + } + + function isSecureWebUri(url) { + return isUrl(url) && url.toLowerCase().startsWith("https"); + } + + function assertOrigin(url) { + assertUrl(url); + if (url.search(/^https?:/i) !== -1) { + let newUrl = new URL(url); + if (newUrl.pathname != "/") { + throw new Error("Bad origin, might include path"); + } + } + } + + function originFromUrl(url) { + if (!url) { + return null; + } + if (url.search(/^https?:/i) === -1) { + // Non-HTTP URLs don't have an origin + return null; + } + try { + let tryUrl = new URL(url); + return tryUrl.origin; + } catch { + return null; + } + } + + /** Check if the given object has all of the required attributes, and no extra + attributes exception those in optional */ + function checkObject(obj, required, optional) { + if (typeof obj !== "object" || obj === null) { + throw new Error( + "Cannot check non-object: " + + typeof obj + + " that is " + + JSON.stringify(obj) + ); + } + required = required || []; + for (const attr of required) { + if (!(attr in obj)) { + return false; + } + } + optional = optional || []; + for (const attr in obj) { + if (!required.includes(attr) && !optional.includes(attr)) { + return false; + } + } + return true; + } + + /** Create a JSON object from a normal object, given the required and optional + attributes (filtering out any other attributes). Optional attributes are + only kept when they are truthy. */ + function jsonify(obj, required, optional) { + required = required || []; + const result = {}; + for (const attr of required) { + result[attr] = obj[attr]; + } + optional = optional || []; + for (const attr of optional) { + if (obj[attr]) { + result[attr] = obj[attr]; + } + } + return result; + } + + /** True if the two objects look alike. Null, undefined, and absent properties + are all treated as equivalent. Traverses objects and arrays */ + function deepEqual(a, b) { + if ((a === null || a === undefined) && (b === null || b === undefined)) { + return true; + } + if (typeof a !== "object" || typeof b !== "object") { + return a === b; + } + if (Array.isArray(a)) { + if (!Array.isArray(b)) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + } + if (Array.isArray(b)) { + return false; + } + const seen = new Set(); + for (const attr of Object.keys(a)) { + if (!deepEqual(a[attr], b[attr])) { + return false; + } + seen.add(attr); + } + for (const attr of Object.keys(b)) { + if (!seen.has(attr)) { + if (!deepEqual(a[attr], b[attr])) { + return false; + } + } + } + return true; + } + + function makeRandomId() { + // Note: this isn't for secure contexts, only for non-conflicting IDs + let id = ""; + while (id.length < 12) { + let num; + if (!id) { + num = Date.now() % Math.pow(36, 3); + } else { + num = Math.floor(Math.random() * Math.pow(36, 3)); + } + id += num.toString(36); + } + return id; + } + + class AbstractShot { + constructor(backend, id, attrs) { + attrs = attrs || {}; + assert( + /^[a-zA-Z0-9]{1,4000}\/[a-z0-9._-]{1,4000}$/.test(id), + "Bad ID (should be alphanumeric):", + JSON.stringify(id) + ); + this._backend = backend; + this._id = id; + this.origin = attrs.origin || null; + this.fullUrl = attrs.fullUrl || null; + if (!attrs.fullUrl && attrs.url) { + console.warn("Received deprecated attribute .url"); + this.fullUrl = attrs.url; + } + if (this.origin && !isSecureWebUri(this.origin)) { + this.origin = ""; + } + if (this.fullUrl && !isSecureWebUri(this.fullUrl)) { + this.fullUrl = ""; + } + this.docTitle = attrs.docTitle || null; + this.userTitle = attrs.userTitle || null; + this.createdDate = attrs.createdDate || Date.now(); + this.siteName = attrs.siteName || null; + this.images = []; + if (attrs.images) { + this.images = attrs.images.map(json => new this.Image(json)); + } + this.openGraph = attrs.openGraph || null; + this.twitterCard = attrs.twitterCard || null; + this.documentSize = attrs.documentSize || null; + this.thumbnail = attrs.thumbnail || null; + this.abTests = attrs.abTests || null; + this.firefoxChannel = attrs.firefoxChannel || null; + this._clips = {}; + if (attrs.clips) { + for (const clipId in attrs.clips) { + const clip = attrs.clips[clipId]; + this._clips[clipId] = new this.Clip(this, clipId, clip); + } + } + + const isProd = + typeof process !== "undefined" && process.env.NODE_ENV === "production"; + + for (const attr in attrs) { + if ( + attr !== "clips" && + attr !== "id" && + !this.REGULAR_ATTRS.includes(attr) && + !this.DEPRECATED_ATTRS.includes(attr) + ) { + if (isProd) { + console.warn("Unexpected attribute: " + attr); + } else { + throw new Error("Unexpected attribute: " + attr); + } + } else if (attr === "id") { + console.warn("passing id in attrs in AbstractShot constructor"); + console.trace(); + assert(attrs.id === this.id); + } + } + } + + /** Update any and all attributes in the json object, with deep updating + of `json.clips` */ + update(json) { + const ALL_ATTRS = ["clips"].concat(this.REGULAR_ATTRS); + assert( + checkObject(json, [], ALL_ATTRS), + "Bad attr to new Shot():", + Object.keys(json) + ); + for (const attr in json) { + if (attr === "clips") { + continue; + } + if ( + typeof json[attr] === "object" && + typeof this[attr] === "object" && + this[attr] !== null + ) { + let val = this[attr]; + if (val.toJSON) { + val = val.toJSON(); + } + if (!deepEqual(json[attr], val)) { + this[attr] = json[attr]; + } + } else if (json[attr] !== this[attr] && (json[attr] || this[attr])) { + this[attr] = json[attr]; + } + } + if (json.clips) { + for (const clipId in json.clips) { + if (!json.clips[clipId]) { + this.delClip(clipId); + } else if (!this.getClip(clipId)) { + this.setClip(clipId, json.clips[clipId]); + } else if ( + !deepEqual(this.getClip(clipId).toJSON(), json.clips[clipId]) + ) { + this.setClip(clipId, json.clips[clipId]); + } + } + } + } + + /** Returns a JSON version of this shot */ + toJSON() { + const result = {}; + for (const attr of this.REGULAR_ATTRS) { + let val = this[attr]; + if (val && val.toJSON) { + val = val.toJSON(); + } + result[attr] = val; + } + result.clips = {}; + for (const attr in this._clips) { + result.clips[attr] = this._clips[attr].toJSON(); + } + return result; + } + + /** A more minimal JSON representation for creating indexes of shots */ + asRecallJson() { + const result = { clips: {} }; + for (const attr of this.RECALL_ATTRS) { + let val = this[attr]; + if (val && val.toJSON) { + val = val.toJSON(); + } + result[attr] = val; + } + for (const name of this.clipNames()) { + result.clips[name] = this.getClip(name).toJSON(); + } + return result; + } + + get backend() { + return this._backend; + } + + get id() { + return this._id; + } + + get url() { + return this.fullUrl || this.origin; + } + set url(val) { + throw new Error(".url is read-only"); + } + + get fullUrl() { + return this._fullUrl; + } + set fullUrl(val) { + if (val) { + assertUrl(val); + } + this._fullUrl = val || undefined; + } + + get origin() { + return this._origin; + } + set origin(val) { + if (val) { + assertOrigin(val); + } + this._origin = val || undefined; + } + + get isOwner() { + return this._isOwner; + } + + set isOwner(val) { + this._isOwner = val || undefined; + } + + get filename() { + let filenameTitle = this.title; + const date = new Date(this.createdDate); + /* eslint-disable no-control-regex */ + filenameTitle = filenameTitle + .replace(/[\\/]/g, "_") + .replace(/[\u200e\u200f\u202a-\u202e]/g, "") + .replace(/[\x00-\x1f\x7f-\x9f:*?|"<>;,+=\[\]]+/g, " ") + .replace(/^[\s\u180e.]+|[\s\u180e.]+$/g, ""); + /* eslint-enable no-control-regex */ + filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " "); + const currentDateTime = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000 + ).toISOString(); + const filenameDate = currentDateTime.substring(0, 10); + const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-"); + let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`; + + // Crop the filename size at less than 246 bytes, so as to leave + // room for the extension and an ellipsis [...]. Note that JS + // strings are UTF16 but the filename will be converted to UTF8 + // when saving which could take up more space, and we want a + // maximum of 255 bytes (not characters). Here, we iterate + // and crop at shorter and shorter points until we fit into + // 255 bytes. + let suffix = ""; + for (let cropSize = 246; cropSize >= 0; cropSize -= 32) { + if (new Blob([clipFilename]).size > 246) { + clipFilename = clipFilename.substring(0, cropSize); + suffix = "[...]"; + } else { + break; + } + } + + clipFilename += suffix; + + const clip = this.getClip(this.clipNames()[0]); + let extension = ".png"; + if (clip && clip.image && clip.image.type) { + if (clip.image.type === "jpeg") { + extension = ".jpg"; + } + } + return clipFilename + extension; + } + + get urlDisplay() { + if (!this.url) { + return null; + } + if (/^https?:\/\//i.test(this.url)) { + let txt = this.url; + txt = txt.replace(/^[a-z]{1,4000}:\/\//i, ""); + txt = txt.replace(/\/.{0,4000}/, ""); + txt = txt.replace(/^www\./i, ""); + return txt; + } else if (this.url.startsWith("data:")) { + return "data:url"; + } + let txt = this.url; + txt = txt.replace(/\?.{0,4000}/, ""); + return txt; + } + + get viewUrl() { + const url = this.backend + "/" + this.id; + return url; + } + + get creatingUrl() { + let url = `${this.backend}/creating/${this.id}`; + url += `?title=${encodeURIComponent(this.title || "")}`; + url += `&url=${encodeURIComponent(this.url)}`; + return url; + } + + get jsonUrl() { + return this.backend + "/data/" + this.id; + } + + get oembedUrl() { + return this.backend + "/oembed?url=" + encodeURIComponent(this.viewUrl); + } + + get docTitle() { + return this._title; + } + set docTitle(val) { + assert(val === null || typeof val === "string", "Bad docTitle:", val); + this._title = val; + } + + get openGraph() { + return this._openGraph || null; + } + set openGraph(val) { + assert(val === null || typeof val === "object", "Bad openGraph:", val); + if (val) { + assert( + checkObject(val, [], this._OPENGRAPH_PROPERTIES), + "Bad attr to openGraph:", + Object.keys(val) + ); + this._openGraph = val; + } else { + this._openGraph = null; + } + } + + get twitterCard() { + return this._twitterCard || null; + } + set twitterCard(val) { + assert(val === null || typeof val === "object", "Bad twitterCard:", val); + if (val) { + assert( + checkObject(val, [], this._TWITTERCARD_PROPERTIES), + "Bad attr to twitterCard:", + Object.keys(val) + ); + this._twitterCard = val; + } else { + this._twitterCard = null; + } + } + + get userTitle() { + return this._userTitle; + } + set userTitle(val) { + assert(val === null || typeof val === "string", "Bad userTitle:", val); + this._userTitle = val; + } + + get title() { + // FIXME: we shouldn't support both openGraph.title and ogTitle + const ogTitle = this.openGraph && this.openGraph.title; + const twitterTitle = this.twitterCard && this.twitterCard.title; + let title = + this.userTitle || ogTitle || twitterTitle || this.docTitle || this.url; + if (Array.isArray(title)) { + title = title[0]; + } + if (!title) { + title = "Screenshot"; + } + return title; + } + + get createdDate() { + return this._createdDate; + } + set createdDate(val) { + assert(val === null || typeof val === "number", "Bad createdDate:", val); + this._createdDate = val; + } + + clipNames() { + const names = Object.getOwnPropertyNames(this._clips); + names.sort(function (a, b) { + return a.sortOrder < b.sortOrder ? 1 : 0; + }); + return names; + } + getClip(name) { + return this._clips[name]; + } + addClip(val) { + const name = makeRandomId(); + this.setClip(name, val); + return name; + } + setClip(name, val) { + const clip = new this.Clip(this, name, val); + this._clips[name] = clip; + } + delClip(name) { + if (!this._clips[name]) { + throw new Error("No existing clip with id: " + name); + } + delete this._clips[name]; + } + delAllClips() { + this._clips = {}; + } + biggestClipSortOrder() { + let biggest = 0; + for (const clipId in this._clips) { + biggest = Math.max(biggest, this._clips[clipId].sortOrder); + } + return biggest; + } + updateClipUrl(clipId, clipUrl) { + const clip = this.getClip(clipId); + if (clip && clip.image) { + clip.image.url = clipUrl; + } else { + console.warn("Tried to update the url of a clip with no image:", clip); + } + } + + get siteName() { + return this._siteName || null; + } + set siteName(val) { + assert(typeof val === "string" || !val); + this._siteName = val; + } + + get documentSize() { + return this._documentSize; + } + set documentSize(val) { + assert(typeof val === "object" || !val); + if (val) { + assert( + checkObject( + val, + ["height", "width"], + "Bad attr to documentSize:", + Object.keys(val) + ) + ); + assert(typeof val.height === "number"); + assert(typeof val.width === "number"); + this._documentSize = val; + } else { + this._documentSize = null; + } + } + + get thumbnail() { + return this._thumbnail; + } + set thumbnail(val) { + assert(typeof val === "string" || !val); + if (val) { + assert(isUrl(val)); + this._thumbnail = val; + } else { + this._thumbnail = null; + } + } + + get abTests() { + return this._abTests; + } + set abTests(val) { + if (val === null || val === undefined) { + this._abTests = null; + return; + } + assert( + typeof val === "object", + "abTests should be an object, not:", + typeof val + ); + assert(!Array.isArray(val), "abTests should not be an Array"); + for (const name in val) { + assert( + val[name] && typeof val[name] === "string", + `abTests.${name} should be a string:`, + typeof val[name] + ); + } + this._abTests = val; + } + + get firefoxChannel() { + return this._firefoxChannel; + } + set firefoxChannel(val) { + if (val === null || val === undefined) { + this._firefoxChannel = null; + return; + } + assert( + typeof val === "string", + "firefoxChannel should be a string, not:", + typeof val + ); + this._firefoxChannel = val; + } + } + + AbstractShot.prototype.REGULAR_ATTRS = ` +origin fullUrl docTitle userTitle createdDate images +siteName openGraph twitterCard documentSize +thumbnail abTests firefoxChannel +`.split(/\s+/g); + + // Attributes that will be accepted in the constructor, but ignored/dropped + AbstractShot.prototype.DEPRECATED_ATTRS = ` +microdata history ogTitle createdDevice head body htmlAttrs bodyAttrs headAttrs +readable hashtags comments showPage isPublic resources url +fullScreenThumbnail favicon +`.split(/\s+/g); + + AbstractShot.prototype.RECALL_ATTRS = ` +url docTitle userTitle createdDate openGraph twitterCard images thumbnail +`.split(/\s+/g); + + AbstractShot.prototype._OPENGRAPH_PROPERTIES = ` +title type url image audio description determiner locale site_name video +image:secure_url image:type image:width image:height +video:secure_url video:type video:width image:height +audio:secure_url audio:type +article:published_time article:modified_time article:expiration_time article:author article:section article:tag +book:author book:isbn book:release_date book:tag +profile:first_name profile:last_name profile:username profile:gender +`.split(/\s+/g); + + AbstractShot.prototype._TWITTERCARD_PROPERTIES = ` +card site title description image +player player:width player:height player:stream player:stream:content_type +`.split(/\s+/g); + + /** Represents one found image in the document (not a clip) */ + class _Image { + // FIXME: either we have to notify the shot of updates, or make + // this read-only + constructor(json) { + assert(typeof json === "object", "Clip Image given a non-object", json); + assert( + checkObject(json, ["url"], ["dimensions", "title", "alt"]), + "Bad attrs for Image:", + Object.keys(json) + ); + assert(isUrl(json.url), "Bad Image url:", json.url); + this.url = json.url; + assert( + !json.dimensions || + (typeof json.dimensions.x === "number" && + typeof json.dimensions.y === "number"), + "Bad Image dimensions:", + json.dimensions + ); + this.dimensions = json.dimensions; + assert( + typeof json.title === "string" || !json.title, + "Bad Image title:", + json.title + ); + this.title = json.title; + assert( + typeof json.alt === "string" || !json.alt, + "Bad Image alt:", + json.alt + ); + this.alt = json.alt; + } + + toJSON() { + return jsonify(this, ["url"], ["dimensions"]); + } + } + + AbstractShot.prototype.Image = _Image; + + /** Represents a clip, either a text or image clip */ + class _Clip { + constructor(shot, id, json) { + this._shot = shot; + assert( + checkObject(json, ["createdDate", "image"], ["sortOrder"]), + "Bad attrs for Clip:", + Object.keys(json) + ); + assert(typeof id === "string" && id, "Bad Clip id:", id); + this._id = id; + this.createdDate = json.createdDate; + if ("sortOrder" in json) { + assert( + typeof json.sortOrder === "number" || !json.sortOrder, + "Bad Clip sortOrder:", + json.sortOrder + ); + } + if ("sortOrder" in json) { + this.sortOrder = json.sortOrder; + } else { + const biggestOrder = shot.biggestClipSortOrder(); + this.sortOrder = biggestOrder + 100; + } + this.image = json.image; + } + + toString() { + return `[Shot Clip id=${this.id} sortOrder=${this.sortOrder} image ${this.image.dimensions.x}x${this.image.dimensions.y}]`; + } + + toJSON() { + return jsonify(this, ["createdDate"], ["sortOrder", "image"]); + } + + get id() { + return this._id; + } + + get createdDate() { + return this._createdDate; + } + set createdDate(val) { + assert(typeof val === "number" || !val, "Bad Clip createdDate:", val); + this._createdDate = val; + } + + get image() { + return this._image; + } + set image(image) { + if (!image) { + this._image = undefined; + return; + } + assert( + checkObject( + image, + ["url"], + ["dimensions", "text", "location", "captureType", "type"] + ), + "Bad attrs for Clip Image:", + Object.keys(image) + ); + assert(isValidClipImageUrl(image.url), "Bad Clip image URL:", image.url); + assert( + image.captureType === "madeSelection" || + image.captureType === "selection" || + image.captureType === "visible" || + image.captureType === "auto" || + image.captureType === "fullPage" || + image.captureType === "fullPageTruncated" || + !image.captureType, + "Bad image.captureType:", + image.captureType + ); + assert( + typeof image.text === "string" || !image.text, + "Bad Clip image text:", + image.text + ); + if (image.dimensions) { + assert( + typeof image.dimensions.x === "number" && + typeof image.dimensions.y === "number", + "Bad Clip image dimensions:", + image.dimensions + ); + } + if (image.type) { + assert( + image.type === "png" || image.type === "jpeg", + "Unexpected image type:", + image.type + ); + } + assert( + image.location && + typeof image.location.left === "number" && + typeof image.location.right === "number" && + typeof image.location.top === "number" && + typeof image.location.bottom === "number", + "Bad Clip image pixel location:", + image.location + ); + if ( + image.location.topLeftElement || + image.location.topLeftOffset || + image.location.bottomRightElement || + image.location.bottomRightOffset + ) { + assert( + typeof image.location.topLeftElement === "string" && + image.location.topLeftOffset && + typeof image.location.topLeftOffset.x === "number" && + typeof image.location.topLeftOffset.y === "number" && + typeof image.location.bottomRightElement === "string" && + image.location.bottomRightOffset && + typeof image.location.bottomRightOffset.x === "number" && + typeof image.location.bottomRightOffset.y === "number", + "Bad Clip image element location:", + image.location + ); + } + this._image = image; + } + + isDataUrl() { + if (this.image) { + return this.image.url.startsWith("data:"); + } + return false; + } + + get sortOrder() { + return this._sortOrder || null; + } + set sortOrder(val) { + assert(typeof val === "number" || !val, "Bad Clip sortOrder:", val); + this._sortOrder = val; + } + } + + AbstractShot.prototype.Clip = _Clip; + + if (typeof exports !== "undefined") { + exports.AbstractShot = AbstractShot; + exports.originFromUrl = originFromUrl; + exports.isValidClipImageUrl = isValidClipImageUrl; + } + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/build/thumbnailGenerator.js b/browser/extensions/screenshots/build/thumbnailGenerator.js new file mode 100644 index 0000000000..c80ccb6bac --- /dev/null +++ b/browser/extensions/screenshots/build/thumbnailGenerator.js @@ -0,0 +1,190 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.thumbnailGenerator = (function () { + let exports = {}; // This is used in webextension/background/takeshot.js, + // server/src/pages/shot/controller.js, and + // server/scr/pages/shotindex/view.js. It is used in a browser + // environment. + + // Resize down 1/2 at a time produces better image quality. + // Not quite as good as using a third-party filter (which will be + // slower), but good enough. + const maxResizeScaleFactor = 0.5; + + // The shot will be scaled or cropped down to 210px on x, and cropped or + // scaled down to a maximum of 280px on y. + // x: 210 + // y: <= 280 + const maxThumbnailWidth = 210; + const maxThumbnailHeight = 280; + + /** + * @param {int} imageHeight Height in pixels of the original image. + * @param {int} imageWidth Width in pixels of the original image. + * @returns {width, height, scaledX, scaledY} + */ + function getThumbnailDimensions(imageWidth, imageHeight) { + const displayAspectRatio = 3 / 4; + const imageAspectRatio = imageWidth / imageHeight; + let thumbnailImageWidth, thumbnailImageHeight; + let scaledX, scaledY; + + if (imageAspectRatio > displayAspectRatio) { + // "Landscape" mode + // Scale on y, crop on x + const yScaleFactor = + imageHeight > maxThumbnailHeight + ? maxThumbnailHeight / imageHeight + : 1.0; + thumbnailImageHeight = scaledY = Math.round(imageHeight * yScaleFactor); + scaledX = Math.round(imageWidth * yScaleFactor); + thumbnailImageWidth = Math.min(scaledX, maxThumbnailWidth); + } else { + // "Portrait" mode + // Scale on x, crop on y + const xScaleFactor = + imageWidth > maxThumbnailWidth ? maxThumbnailWidth / imageWidth : 1.0; + thumbnailImageWidth = scaledX = Math.round(imageWidth * xScaleFactor); + scaledY = Math.round(imageHeight * xScaleFactor); + // The CSS could widen the image, in which case we crop more off of y. + thumbnailImageHeight = Math.min( + scaledY, + maxThumbnailHeight, + maxThumbnailHeight / (maxThumbnailWidth / imageWidth) + ); + } + + return { + width: thumbnailImageWidth, + height: thumbnailImageHeight, + scaledX, + scaledY, + }; + } + + /** + * @param {dataUrl} String Data URL of the original image. + * @param {int} imageHeight Height in pixels of the original image. + * @param {int} imageWidth Width in pixels of the original image. + * @param {String} urlOrBlob 'blob' for a blob, otherwise data url. + * @returns A promise that resolves to the data URL or blob of the thumbnail image, or null. + */ + function createThumbnail(dataUrl, imageWidth, imageHeight, urlOrBlob) { + // There's cost associated with generating, transmitting, and storing + // thumbnails, so we'll opt out if the image size is below a certain threshold + const thumbnailThresholdFactor = 1.2; + const thumbnailWidthThreshold = + maxThumbnailWidth * thumbnailThresholdFactor; + const thumbnailHeightThreshold = + maxThumbnailHeight * thumbnailThresholdFactor; + + if ( + imageWidth <= thumbnailWidthThreshold && + imageHeight <= thumbnailHeightThreshold + ) { + // Do not create a thumbnail. + return Promise.resolve(null); + } + + const thumbnailDimensions = getThumbnailDimensions(imageWidth, imageHeight); + + return new Promise((resolve, reject) => { + const thumbnailImage = new Image(); + let srcWidth = imageWidth; + let srcHeight = imageHeight; + let destWidth, destHeight; + + thumbnailImage.onload = function () { + destWidth = Math.round(srcWidth * maxResizeScaleFactor); + destHeight = Math.round(srcHeight * maxResizeScaleFactor); + if ( + destWidth <= thumbnailDimensions.scaledX || + destHeight <= thumbnailDimensions.scaledY + ) { + srcWidth = Math.round( + srcWidth * (thumbnailDimensions.width / thumbnailDimensions.scaledX) + ); + srcHeight = Math.round( + srcHeight * + (thumbnailDimensions.height / thumbnailDimensions.scaledY) + ); + destWidth = thumbnailDimensions.width; + destHeight = thumbnailDimensions.height; + } + + const thumbnailCanvas = document.createElement("canvas"); + thumbnailCanvas.width = destWidth; + thumbnailCanvas.height = destHeight; + const ctx = thumbnailCanvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + ctx.drawImage( + thumbnailImage, + 0, + 0, + srcWidth, + srcHeight, + 0, + 0, + destWidth, + destHeight + ); + + if ( + thumbnailCanvas.width <= thumbnailDimensions.width || + thumbnailCanvas.height <= thumbnailDimensions.height + ) { + if (urlOrBlob === "blob") { + thumbnailCanvas.toBlob(blob => { + resolve(blob); + }); + } else { + resolve(thumbnailCanvas.toDataURL("image/png")); + } + return; + } + + srcWidth = destWidth; + srcHeight = destHeight; + thumbnailImage.src = thumbnailCanvas.toDataURL(); + }; + thumbnailImage.src = dataUrl; + }); + } + + function createThumbnailUrl(shot) { + const image = shot.getClip(shot.clipNames()[0]).image; + if (!image.url) { + return Promise.resolve(null); + } + return createThumbnail( + image.url, + image.dimensions.x, + image.dimensions.y, + "dataurl" + ); + } + + function createThumbnailBlobFromPromise(shot, blobToUrlPromise) { + return blobToUrlPromise.then(dataUrl => { + const image = shot.getClip(shot.clipNames()[0]).image; + return createThumbnail( + dataUrl, + image.dimensions.x, + image.dimensions.y, + "blob" + ); + }); + } + + if (typeof exports !== "undefined") { + exports.getThumbnailDimensions = getThumbnailDimensions; + exports.createThumbnailUrl = createThumbnailUrl; + exports.createThumbnailBlobFromPromise = createThumbnailBlobFromPromise; + } + + return exports; +})(); +null; |