Przeglądaj źródła

added new selection mode triggered when no content is selected

Gildas 7 lat temu
rodzic
commit
b201a2d249

+ 53 - 29
extension/core/content/content.js

@@ -23,6 +23,7 @@
 this.singlefile.top = this.singlefile.top || (() => {
 
 	const MESSAGE_PREFIX = "__SingleFile__::";
+	const SingleFileClass = SingleFile.getClass();
 
 	let processing = false;
 
@@ -45,21 +46,43 @@ this.singlefile.top = this.singlefile.top || (() => {
 	async function savePage(message) {
 		const options = message.options;
 		if (!processing && !options.frameId) {
-			processing = true;
-			try {
-				const page = await processPage(options);
-				await downloadPage(page, options);
-			} catch (error) {
-				console.error(error); // eslint-disable-line no-console
-				browser.runtime.sendMessage({ processError: true, error, options: { autoSave: false } });
+			let selectionFound;
+			if (options.selected) {
+				selectionFound = await markSelection();
+			}
+			if (!options.selected || selectionFound) {
+				processing = true;
+				try {
+					const page = await processPage(options);
+					await downloadPage(page, options);
+				} catch (error) {
+					console.error(error); // eslint-disable-line no-console
+					browser.runtime.sendMessage({ processError: true, error, options: { autoSave: false } });
+				}
+			} else {
+				browser.runtime.sendMessage({ processCancelled: true, options: { autoSave: false } });
 			}
 			processing = false;
 		}
 	}
 
+	async function markSelection() {
+		let selectionFound = markSelectedContent();
+		if (selectionFound) {
+			return selectionFound;
+		} else {
+			const selectedArea = await singlefile.ui.getSelectedArea();
+			if (selectedArea) {
+				markSelectedArea(selectedArea);
+				selectionFound = true;
+			}
+			return selectionFound;
+		}
+	}
+
 	async function processPage(options) {
 		singlefile.ui.init();
-		const processor = new (SingleFile.getClass())(options);
+		const processor = new SingleFileClass(options);
 		options.insertSingleFileComment = true;
 		options.insertFaviconLink = true;
 		if (!options.removeFrames && this.frameTree) {
@@ -84,12 +107,6 @@ this.singlefile.top = this.singlefile.top || (() => {
 				browser.runtime.sendMessage({ processEnd: true, options: { autoSave: false } });
 			}
 		};
-		if (options.selected) {
-			const selectionFound = markSelectedContent(processor.SELECTED_CONTENT_ATTRIBUTE_NAME, processor.SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME);
-			if (!selectionFound) {
-				options.selected = false;
-			}
-		}
 		if (options.lazyLoadImages) {
 			await lazyLoadResources();
 		}
@@ -97,7 +114,7 @@ this.singlefile.top = this.singlefile.top || (() => {
 		await processor.preparePageData();
 		const page = await processor.getPageData();
 		if (options.selected) {
-			unmarkSelectedContent(processor.SELECTED_CONTENT_ATTRIBUTE_NAME, processor.SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME);
+			unmarkSelectedContent();
 		}
 		page.url = URL.createObjectURL(new Blob([page.content], { type: "text/html" }));
 		if (options.shadowEnabled) {
@@ -130,30 +147,37 @@ this.singlefile.top = this.singlefile.top || (() => {
 		return promise;
 	}
 
-	function markSelectedContent(SELECTED_CONTENT_ATTRIBUTE_NAME, SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME) {
+	function markSelectedContent() {
 		const selection = getSelection();
 		const range = selection.rangeCount ? selection.getRangeAt(0) : null;
-		const treeWalker = document.createTreeWalker(range.commonAncestorContainer);
 		let selectionFound = false;
-		const ancestorElement = range.commonAncestorContainer != Node.ELEMENT_NODE ? range.commonAncestorContainer.parentElement : range.commonAncestorContainer;
-		while (treeWalker.nextNode() && treeWalker.currentNode != range.endContainer) {
-			if (treeWalker.currentNode == range.startContainer) {
-				selectionFound = true;
+		if (range && range.commonAncestorContainer) {
+			const treeWalker = document.createTreeWalker(range.commonAncestorContainer);
+			const ancestorElement = range.commonAncestorContainer != Node.ELEMENT_NODE ? range.commonAncestorContainer.parentElement : range.commonAncestorContainer;
+			while (treeWalker.nextNode() && treeWalker.currentNode != range.endContainer) {
+				if (treeWalker.currentNode == range.startContainer) {
+					selectionFound = true;
+				}
+				if (selectionFound) {
+					const element = treeWalker.currentNode.nodeType == Node.ELEMENT_NODE ? treeWalker.currentNode : treeWalker.currentNode.parentElement;
+					element.setAttribute(SingleFileClass.SELECTED_CONTENT_ATTRIBUTE_NAME, "");
+				}
 			}
 			if (selectionFound) {
-				const element = treeWalker.currentNode.nodeType == Node.ELEMENT_NODE ? treeWalker.currentNode : treeWalker.currentNode.parentElement;
-				element.setAttribute(SELECTED_CONTENT_ATTRIBUTE_NAME, "");
+				ancestorElement.setAttribute(SingleFileClass.SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME, "");
 			}
 		}
-		if (selectionFound) {
-			ancestorElement.setAttribute(SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME, "");
-		}
 		return selectionFound;
 	}
 
-	function unmarkSelectedContent(SELECTED_CONTENT_ATTRIBUTE_NAME, SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME) {
-		document.querySelectorAll("[" + SELECTED_CONTENT_ATTRIBUTE_NAME + "]").forEach(selectedContent => selectedContent.removeAttribute(SELECTED_CONTENT_ATTRIBUTE_NAME));
-		document.querySelectorAll("[" + SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME + "]").forEach(selectedContent => selectedContent.removeAttribute(SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME));
+	function markSelectedArea(selectedAreaElement) {
+		selectedAreaElement.setAttribute(SingleFileClass.SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME, "");
+		selectedAreaElement.querySelectorAll("*").forEach(element => element.setAttribute(SingleFileClass.SELECTED_CONTENT_ATTRIBUTE_NAME, ""));
+	}
+
+	function unmarkSelectedContent() {
+		document.querySelectorAll("[" + SingleFileClass.SELECTED_CONTENT_ATTRIBUTE_NAME + "]").forEach(selectedContent => selectedContent.removeAttribute(SingleFileClass.SELECTED_CONTENT_ATTRIBUTE_NAME));
+		document.querySelectorAll("[" + SingleFileClass.SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME + "]").forEach(selectedContent => selectedContent.removeAttribute(SingleFileClass.SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME));
 	}
 
 	async function downloadPage(page, options) {

+ 1 - 1
extension/ui/bg/ui-menu.js

@@ -69,7 +69,7 @@ singlefile.ui.menu = (() => {
 			}
 			browser.menus.create({
 				id: MENU_ID_SAVE_SELECTED,
-				contexts: config.contextMenuEnabled ? defaultContextsDisabled.concat(["selection"]) : defaultContextsDisabled,
+				contexts: config.contextMenuEnabled ? defaultContextsDisabled.concat("selection", "page", "image") : defaultContextsDisabled,
 				title: browser.i18n.getMessage("menuSaveSelection")
 			});
 			if (config.contextMenuEnabled) {

+ 117 - 2
extension/ui/content/content-ui.js

@@ -18,12 +18,15 @@
  *   along with SingleFile.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-/* global document, getComputedStyle, requestAnimationFrame */
+/* global document, getComputedStyle, addEventListener, removeEventListener, requestAnimationFrame, scrollX, scrollY */
 
 this.singlefile.ui = this.singlefile.ui || (() => {
 
 	const MASK_TAGNAME = "singlefile-mask";
 	const PROGRESS_BAR_TAGNAME = "singlefile-progress-var";
+	const SELECT_PX_THRESHOLD = 8;
+
+	let selectedAreaElement;
 
 	return {
 		init() {
@@ -72,9 +75,121 @@ this.singlefile.ui = this.singlefile.ui || (() => {
 			if (maskElement) {
 				requestAnimationFrame(() => maskElement.remove());
 			}
-		}
+		},
+		getSelectedArea
 	};
 
+	function getSelectedArea() {
+		return new Promise(resolve => {
+			addEventListener("mousemove", mousemoveListener, true);
+			addEventListener("click", clickListener, true);
+			addEventListener("keyup", keypressListener, true);
+
+			function mousemoveListener(event) {
+				const targetElement = getTarget(event);
+				if (targetElement) {
+					selectedAreaElement = targetElement;
+					moveAreaSelector(targetElement);
+				}
+			}
+
+			function clickListener(event) {
+				select(selectedAreaElement);
+				event.preventDefault();
+				event.stopPropagation();
+			}
+
+			function keypressListener(event) {
+				if (event.key == "Escape") {
+					select();
+				}
+			}
+
+			function select(selectedElement) {
+				removeEventListener("mousemove", mousemoveListener, true);
+				removeEventListener("click", clickListener, true);
+				removeEventListener("keyup", keypressListener, true);
+				getAreaSelector().remove();
+				resolve(selectedElement);
+				selectedAreaElement = null;
+			}
+		});
+	}
+
+	function getTarget(event) {
+		let newTarget, target = event.target, boundingRect = target.getBoundingClientRect();
+		newTarget = determineTargetElement(target, event.clientX - boundingRect.left, getMatchedParents(target, "left"));
+		if (newTarget == target) {
+			newTarget = determineTargetElement(target, boundingRect.left + boundingRect.width - event.clientX, getMatchedParents(target, "right"));
+		}
+		if (newTarget == target) {
+			newTarget = determineTargetElement(target, event.clientY - boundingRect.top, getMatchedParents(target, "top"));
+		}
+		if (newTarget == target) {
+			newTarget = determineTargetElement(target, boundingRect.top + boundingRect.height - event.clientY, getMatchedParents(target, "bottom"));
+		}
+		target = newTarget;
+		while (target && target.clientWidth <= SELECT_PX_THRESHOLD && target.clientHeight <= SELECT_PX_THRESHOLD) {
+			target = target.parentElement;
+		}
+		return target;
+	}
+
+	function moveAreaSelector(target) {
+		const selectorElement = getAreaSelector();
+		const boundingRect = target.getBoundingClientRect();
+		selectorElement.style.setProperty("top", (scrollY + boundingRect.top - 10) + "px");
+		selectorElement.style.setProperty("left", (scrollX + boundingRect.left - 10) + "px");
+		selectorElement.style.setProperty("width", (boundingRect.width + 20) + "px");
+		selectorElement.style.setProperty("height", (boundingRect.height + 20) + "px");
+	}
+
+	function getAreaSelector() {
+		let selectorElement = document.querySelector("single-file-selection-zone");
+		if (!selectorElement) {
+			selectorElement = createElement("single-file-selection-zone", document.body);
+			selectorElement.style.setProperty("box-sizing", "border-box", "important");
+			selectorElement.style.setProperty("background-color", "#3ea9d7", "important");
+			selectorElement.style.setProperty("border", "10px solid #0b4892", "important");
+			selectorElement.style.setProperty("border-radius", "2px", "important");
+			selectorElement.style.setProperty("opacity", ".25", "important");
+			selectorElement.style.setProperty("pointer-events", "none", "important");
+			selectorElement.style.setProperty("position", "absolute", "important");
+			selectorElement.style.setProperty("transition", "all 100ms", "important");
+			selectorElement.style.setProperty("cursor", "pointer", "important");
+			selectorElement.style.setProperty("z-index", "2147483647", "important");
+		}
+		return selectorElement;
+	}
+
+	function getMatchedParents(target, property) {
+		let element = target, matchedParent, parents = [];
+		do {
+			const boundingRect = element.getBoundingClientRect();
+			if (element.parentElement) {
+				const parentBoundingRect = element.parentElement.getBoundingClientRect();
+				matchedParent = Math.abs(parentBoundingRect[property] - boundingRect[property]) <= SELECT_PX_THRESHOLD;
+				if (matchedParent) {
+					if (element.parentElement.clientWidth > SELECT_PX_THRESHOLD && element.parentElement.clientHeight > SELECT_PX_THRESHOLD &&
+						((element.parentElement.clientWidth - element.clientWidth > SELECT_PX_THRESHOLD) || (element.parentElement.clientHeight - element.clientHeight > SELECT_PX_THRESHOLD))) {
+						parents.push(element.parentElement);
+					}
+					element = element.parentElement;
+				}
+			} else {
+				matchedParent = false;
+			}
+		} while (matchedParent && element);
+		return parents;
+	}
+
+	function determineTargetElement(target, widthDistance, parents) {
+		if (Math.floor(widthDistance / SELECT_PX_THRESHOLD) <= parents.length) {
+			target = parents[parents.length - Math.floor(widthDistance / SELECT_PX_THRESHOLD) - 1];
+		}
+		return target;
+	}
+
 	function createElement(tagName, parentElement) {
 		const element = document.createElement(tagName);
 		parentElement.appendChild(element);

+ 24 - 21
lib/single-file/single-file-core.js

@@ -30,30 +30,33 @@ this.SingleFileCore = this.SingleFileCore || (() => {
 
 	function getClass(...args) {
 		[Download, DOM, URL] = args;
-		return class {
-			constructor(options) {
-				this.options = options;
-				if (options.sessionId === undefined) {
-					options.sessionId = sessionId;
-					sessionId++;
-				}
-				this.SELECTED_CONTENT_ATTRIBUTE_NAME = SELECTED_CONTENT_ATTRIBUTE_NAME;
-				this.SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME = SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME;
-			}
-			async initialize() {
-				this.processor = new PageProcessor(this.options);
-				await this.processor.loadPage();
-				await this.processor.initialize();
-			}
-			async preparePageData() {
-				await this.processor.preparePageData();
-			}
-			async getPageData() {
-				return this.processor.getPageData();
+		return SingleFileClass;
+	}
+
+	class SingleFileClass {
+		constructor(options) {
+			this.options = options;
+			if (options.sessionId === undefined) {
+				options.sessionId = sessionId;
+				sessionId++;
 			}
-		};
+		}
+		async initialize() {
+			this.processor = new PageProcessor(this.options);
+			await this.processor.loadPage();
+			await this.processor.initialize();
+		}
+		async preparePageData() {
+			await this.processor.preparePageData();
+		}
+		async getPageData() {
+			return this.processor.getPageData();
+		}
 	}
 
+	SingleFileClass.SELECTED_CONTENT_ATTRIBUTE_NAME = SELECTED_CONTENT_ATTRIBUTE_NAME;
+	SingleFileClass.SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME = SELECTED_CONTENT_ROOT_ATTRIBUTE_NAME;
+
 	// -------------
 	// ProgressEvent
 	// -------------