Long-press on a page raises a menu, as a right-click does on a desktop
Owner: "long pressing on the page doesn't do anything but it should, like on a
desktop browser". It did something only on links and images; everywhere else
the hold reached the engine as a contextmenu event and that was the end of it.
Now, in all three shells:
on text the word under the finger is selected and the menu offers Copy,
Select All, Back/Forward (when there is somewhere to go),
Reload, Add Bookmark, Find on Page
on a field the field takes the focus with the caret where the press was,
and the menu offers Paste, Select All, and Copy/Cut when
something in it is selected
on nothing the same page menu, without Copy
A press inside what is already selected keeps the selection (so Select All,
then a press, then Copy copies the page), as a right-click does.
How it is put together, within the frozen contract:
- The card does not claim a hold that is not on a link or an image. The
adapter then sends holdAt as it always did; the daemon dispatches the page's
contextmenu event, focuses a field that was not focused, and hands the press
to a new bundled component, components/jihadPageCommands.js, which does the
selecting and answers on the notification channel: page-hold
{editable, selected}. The card that was waiting opens its menu on that.
- Select All, Copy and Cut are the adapter's own selectAll / copy / cut, which
were stubs in the daemon; they now reach the component. Commands act on what
the last press was on: the page's text even while a field has the focus.
- The clipboard belongs to LunaSysMgr and only a card can write or read it.
Copied text therefore goes to the card as a one-call script in the app's own
clip/ directory (the one place a card may load a local file from), announced
as page-copied with the file's name; the file lives fifteen seconds. The
hit-test schema forbids extra keys and the notification channel is public
and cut at 200 characters, so neither could carry it.
- Paste is the card reading the clipboard and sending the text with
insertStringAtCursor. The system's paste is asynchronous and was measured
taking more than 60 ms, so the read watches for up to 600 ms.
- The engine's selectAtPoint adds a range (every word ever pressed stayed
highlighted), takes two words when the press is on a word's last half-letter,
and selects the nearest word for a press on empty space. All three are
corrected in the component.
Mojo's web view claims no hold at all, so there every hold goes this way and
the card the user is in opens the menu.
prerm also removes the clip/ directory. card-selftest.sh reads the gzipped log
generations; the hooks gain h (hold), m (list/pick the menu), c/k (read/set the
clipboard) and p.
On the device (Enyo and Mochi, scripted holds through the adapter's
mouseHoldAt - not a finger): a hold on a word selects that one word and Copy
puts it on the clipboard; a hold on empty space offers no Copy; a hold on a
field, Paste, lands the clipboard at the pressed position; Select All then Cut
empties the field, fires its input event and leaves the text on the clipboard;
Select All on the page with a field focused selects the page, and Copy copies
it. Mojo's menu has not run: it has no test hook and needs a finger.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcrVQFRMC2ANGDfVaRG9Ed
This commit is contained in:
1 parent
3218caadd7
commit
657282c431
20 files changed
+878
-16
No files matched your search
@@ -98,7 +98,8 @@ enyo.kind({
|
||||
onDownloadFinished: "engineDownloadFinished",
|
||||
onDownloadError: "engineDownloadError",
|
||||
// A finger held on a link or an image -> the long-press menu.
|
||||
onHold: "showHoldMenu"
|
||||
onHold: "showHoldMenu",
|
||||
onPageHold: "pageHoldPending"
|
||||
},
|
||||
// Find-in-page bar (overlay below the toolbar); forwards to findInPage.
|
||||
{kind: "JihadFindBar", name: "findBar", onFind: "findRequested", onClose: "findClosed"},
|
||||
@@ -416,6 +417,10 @@ enyo.kind({
|
||||
// request and the sentence to show arrive together.
|
||||
if (resp.category === "chrome-clear-bookmarks") { this.clearBookmarks(); }
|
||||
if (resp.category === "chrome-clear-history") { this.clearHistory(); }
|
||||
// The long-press menu's traffic with the engine: what is under the finger,
|
||||
// and where the copied text was put. Neither is a sentence to show.
|
||||
if (resp.category === "page-hold") { this.pageHold(resp.text); return; }
|
||||
if (resp.category === "page-copied") { this.pageCopied(resp.text); return; }
|
||||
enyo.jihad.toast(resp.category, resp.text);
|
||||
}));
|
||||
},
|
||||
@@ -563,9 +568,11 @@ enyo.kind({
|
||||
this._title = inEvent.title;
|
||||
}
|
||||
if (typeof inEvent.canGoBack === "boolean") {
|
||||
this._canGoBack = inEvent.canGoBack;
|
||||
this.$.back.addRemoveClass("disabled", !inEvent.canGoBack);
|
||||
}
|
||||
if (typeof inEvent.canGoForward === "boolean") {
|
||||
this._canGoForward = inEvent.canGoForward;
|
||||
this.$.forward.addRemoveClass("disabled", !inEvent.canGoForward);
|
||||
}
|
||||
},
|
||||
@@ -725,6 +732,70 @@ enyo.kind({
|
||||
this.openSheet(e.linkUrl ? (e.linkText || "Link") : (e.altText || "Image"), e.linkUrl || e.imageUrl, items, e);
|
||||
return true;
|
||||
},
|
||||
//* JihadWebView.onPageHold: a hold on anything that is not a link or an image went to the
|
||||
//* engine. Its answer arrives as a "page-hold" notification; every card of this variant
|
||||
//* hears that, so only the one waiting for it opens a menu.
|
||||
pageHoldPending: function() {
|
||||
this._pageHold = Date.now();
|
||||
return true;
|
||||
},
|
||||
//* The engine's answer: {editable, selected}. What a desktop browser offers on a
|
||||
//* right-click — on a text field the editing commands, elsewhere the page's.
|
||||
pageHold: function(text) {
|
||||
var at = this._pageHold, s = null, items = [];
|
||||
this._pageHold = 0;
|
||||
if (!at || Date.now() - at > 3000) { return; }
|
||||
try { s = enyo.json.parse(text); } catch (e) { s = null; }
|
||||
s = s || {};
|
||||
if (s.editable) {
|
||||
items.push({label: "Paste", action: "pagePaste"});
|
||||
if (s.selected) {
|
||||
items.push({label: "Copy", action: "pageCopy"});
|
||||
items.push({label: "Cut", action: "pageCut"});
|
||||
}
|
||||
items.push({label: "Select All", action: "pageSelectAll"});
|
||||
this.openSheet("Text Field", "", items, {});
|
||||
return;
|
||||
}
|
||||
if (s.selected) { items.push({label: "Copy", action: "pageCopy"}); }
|
||||
items.push({label: "Select All", action: "pageSelectAll"});
|
||||
if (this._canGoBack) { items.push({label: "Back", action: "goBack"}); }
|
||||
if (this._canGoForward) { items.push({label: "Forward", action: "goForward"}); }
|
||||
items.push({label: "Reload", action: "pageReload"});
|
||||
items.push({label: "Add Bookmark", action: "addCurrentBookmark"});
|
||||
items.push({label: "Find in Page", action: "pageFind"});
|
||||
this.openSheet(this._title || "Page", this.url || "", items, {});
|
||||
},
|
||||
pageSelectAll: function() { this.$.view.callBrowserAdapter("selectAll"); },
|
||||
//* Copy and Cut ask the engine for the selected text; it comes back by way of pageCopied.
|
||||
pageCopy: function() { this.$.view.callBrowserAdapter("copy"); },
|
||||
pageCut: function() { this.$.view.callBrowserAdapter("cut"); },
|
||||
pageReload: function() { this.$.view.callBrowserAdapter("reloadPage"); },
|
||||
pageFind: function() { this.$.findBar.show(); },
|
||||
//* The engine has written the selected text where this card may load it: a one-call
|
||||
//* script in the app's own clip/ directory (a card can load a local file from nowhere
|
||||
//* else, and the clipboard is the card's to write). `name` is that file's name.
|
||||
pageCopied: function(name) {
|
||||
if (!/^copy-[0-9-]+\.js$/.test(String(name))) { return; }
|
||||
var s = document.createElement("script");
|
||||
window.jihadClip = function(text) {
|
||||
window.jihadClip = null;
|
||||
enyo.jihad.toast("info", enyo.jihad.setClipboard(String(text)) ? "Copied." : "The text could not be copied.");
|
||||
};
|
||||
s.onload = s.onerror = function() {
|
||||
if (s.parentNode) { s.parentNode.removeChild(s); }
|
||||
};
|
||||
s.src = "clip/" + name;
|
||||
document.body.appendChild(s);
|
||||
},
|
||||
//* Paste: the clipboard is read here (only a card can) and typed into the focused field.
|
||||
pagePaste: function() {
|
||||
var view = this.$.view;
|
||||
enyo.jihad.getClipboard(function(text) {
|
||||
if (text) { view.callBrowserAdapter("insertStringAtCursor", [String(text)]); }
|
||||
else { enyo.jihad.toast("info", "The clipboard is empty."); }
|
||||
});
|
||||
},
|
||||
holdOpenInNewCard: function(e) { this.openCard({url: e.linkUrl}); },
|
||||
holdShareLink: function(e) { this.openShareSheet({title: e.linkText || e.linkUrl, url: e.linkUrl}); },
|
||||
holdCopyLink: function(e) { this.shareCopy({url: e.linkUrl}); },
|
||||
|
||||
@@ -249,6 +249,32 @@ enyo.jihad = enyo.jihad || {};
|
||||
return ok;
|
||||
};
|
||||
|
||||
//* Read the clipboard and hand its text to `callback`: a throwaway textarea and the
|
||||
//* system's paste into it, which is what enyo.dom.getClipboard does in Enyo 1. The paste
|
||||
//* is NOT synchronous and takes anything from a few milliseconds to more than 60
|
||||
//* (measured: the first read after the keyboard came up was still empty at 60 ms), so
|
||||
//* the textarea is watched for up to 600 ms; only then is the clipboard called empty.
|
||||
scope.getClipboard = function(callback) {
|
||||
var ta = null, tries = 0;
|
||||
try {
|
||||
ta = document.createElement("textarea");
|
||||
ta.style.position = "absolute"; ta.style.left = "-2000px"; ta.style.top = "0";
|
||||
ta.value = "";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
if (window.PalmSystem && window.PalmSystem.paste) { window.PalmSystem.paste(); }
|
||||
else { document.execCommand("paste"); }
|
||||
} catch (e) {}
|
||||
function look() {
|
||||
var text = ta ? ta.value : "";
|
||||
if (!text && ta && ++tries < 15) { setTimeout(look, 40); return; }
|
||||
try { if (ta) { ta.blur(); } } catch (e) {}
|
||||
if (ta && ta.parentNode) { ta.parentNode.removeChild(ta); }
|
||||
callback(text);
|
||||
}
|
||||
setTimeout(look, 20);
|
||||
};
|
||||
|
||||
// --- default search engine -------------------------------------------------
|
||||
// These three, by id. WHICH one is chosen on the settings page (about:preferences, Browser
|
||||
// pane) and cached by JihadChromePrefs; this is only the map from id to search url. The
|
||||
|
||||
@@ -89,7 +89,11 @@ enyo.kind({
|
||||
//* The user held a finger on a link or an image:
|
||||
//* {linkUrl, linkText, imageUrl, altText, pageX, pageY}. Fired only for those two;
|
||||
//* any other hold is left to the engine.
|
||||
onHold: ""
|
||||
onHold: "",
|
||||
//* The user held on anything else. The hold is left to the engine, which answers
|
||||
//* with a "page-hold" notification saying what it found; the shell opens the page
|
||||
//* menu on that. This event only tells the shell an answer is on its way.
|
||||
onPageHold: ""
|
||||
},
|
||||
|
||||
// --- lifecycle ----------------------------------------------------------
|
||||
@@ -402,6 +406,7 @@ enyo.kind({
|
||||
var link = inInfo.isLink ? (inInfo.linkUrl || "") : "";
|
||||
var image = inInfo.isImage ? (inInfo.imageUrl || "") : "";
|
||||
if (!link && !image) {
|
||||
this.doPageHold({pageX: inEvent.pageX, pageY: inEvent.pageY});
|
||||
return false;
|
||||
}
|
||||
this.doHold({
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
// callBrowserAdapter() below is the ONLY app-facing adapter surface, and the set of
|
||||
// method-name literals routed through it is a strict SUBSET of the Enyo-1.0 app's
|
||||
// set in ../../../app/source/Browser.js —
|
||||
// Mojo : {goBack, goForward, reloadPage, stopLoad}
|
||||
// Enyo : {findInPage, goBack, goForward, reloadPage, stopLoad}
|
||||
// — no additions, no renames. This variant uses no palm://com.palm.browserServer/*
|
||||
// Mojo : {copy, cut, findInPage, goBack, goForward, insertStringAtCursor, reloadPage,
|
||||
// selectAll, stopLoad}
|
||||
// Enyo : those and more (dialogs, images, printing, privacy switches)
|
||||
// — no additions, no renames. (copy, cut, selectAll and insertStringAtCursor arrived in both
|
||||
// shells on 2026-10-05 with the long-press menu; all four were already adapter methods.) This variant uses no palm://com.palm.browserServer/*
|
||||
// URI at all (the empty set is a subset of Enyo's {clearCache, clearCookies}).
|
||||
// Since 2026-08-15 it does hold ONE subscription to its OWN per-variant service,
|
||||
// palm://net.riverstonerelay.jihadBrowserMojo/notifications — see subscribeNotifications
|
||||
@@ -381,9 +383,157 @@ MainAssistant.prototype.handleNotification = function (response) {
|
||||
if (response.category === "chrome-clear-history") {
|
||||
JihadHistory.clear();
|
||||
}
|
||||
// The long-press menu's traffic with the engine: what is under the finger, and where the
|
||||
// copied text was put. Neither is a sentence to show.
|
||||
if (response.category === "page-hold") {
|
||||
this.handlePageHold(response.text);
|
||||
return;
|
||||
}
|
||||
if (response.category === "page-copied") {
|
||||
this.handlePageCopied(response.text);
|
||||
return;
|
||||
}
|
||||
JihadToast.show(response.category, response.text);
|
||||
};
|
||||
|
||||
// --- long-press menu -----------------------------------------------------------------------
|
||||
//* A finger held on the page. Mojo.Widget.WebView claims no hold, so every one goes to the
|
||||
//* engine, which gives the page its contextmenu event, selects the word under the finger (or
|
||||
//* focuses the text field) and says what it found: {editable, selected}. The menu is what a
|
||||
//* desktop browser offers on a right-click. Every card of this variant hears the report; only
|
||||
//* the one the user is in acts on it.
|
||||
MainAssistant.prototype.handlePageHold = function (text) {
|
||||
var sc = this.controller.stageController, s = null, items = [];
|
||||
var active = sc.isActiveAndHasScenes ? sc.isActiveAndHasScenes() : sc.active;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
try { s = JSON.parse(text); } catch (e) { s = null; }
|
||||
s = s || {};
|
||||
if (s.editable) {
|
||||
items.push({label: $L("Paste"), command: "page-paste"});
|
||||
if (s.selected) {
|
||||
items.push({label: $L("Copy"), command: "page-copy"});
|
||||
items.push({label: $L("Cut"), command: "page-cut"});
|
||||
}
|
||||
items.push({label: $L("Select All"), command: "page-select-all"});
|
||||
} else {
|
||||
if (s.selected) {
|
||||
items.push({label: $L("Copy"), command: "page-copy"});
|
||||
}
|
||||
items.push({label: $L("Select All"), command: "page-select-all"});
|
||||
if (this.canGoBack) {
|
||||
items.push({label: $L("Back"), command: "page-back"});
|
||||
}
|
||||
if (this.canGoForward) {
|
||||
items.push({label: $L("Forward"), command: "page-forward"});
|
||||
}
|
||||
items.push({label: $L("Reload"), command: "page-reload"});
|
||||
items.push({label: $L("Find on Page"), command: "page-find"});
|
||||
}
|
||||
this.controller.popupSubmenu({
|
||||
onChoose: this.handlePageMenuChoice.bind(this),
|
||||
items: items
|
||||
});
|
||||
};
|
||||
|
||||
MainAssistant.prototype.handlePageMenuChoice = function (command) {
|
||||
switch (command) {
|
||||
case "page-select-all":
|
||||
this.callBrowserAdapter("selectAll");
|
||||
break;
|
||||
case "page-copy":
|
||||
// The text comes back by way of handlePageCopied.
|
||||
this.callBrowserAdapter("copy");
|
||||
break;
|
||||
case "page-cut":
|
||||
this.callBrowserAdapter("cut");
|
||||
break;
|
||||
case "page-paste":
|
||||
this.pasteIntoPage();
|
||||
break;
|
||||
case "page-back":
|
||||
this.callBrowserAdapter("goBack");
|
||||
break;
|
||||
case "page-forward":
|
||||
this.callBrowserAdapter("goForward");
|
||||
break;
|
||||
case "page-reload":
|
||||
this.hideError();
|
||||
this.callBrowserAdapter("reloadPage");
|
||||
break;
|
||||
case "page-find":
|
||||
this.showFind();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
//* The engine has written the selected text where this card may load it: a one-call script in
|
||||
//* the app's own clip/ directory (a card can load a local file from nowhere else, and the
|
||||
//* clipboard is the card's to write). `name` is that file's name.
|
||||
MainAssistant.prototype.handlePageCopied = function (name) {
|
||||
var win = this.controller.window, doc = this.controller.document, sc = this.controller.stageController;
|
||||
var script;
|
||||
if (!/^copy-[0-9-]+\.js$/.test(String(name))) {
|
||||
return;
|
||||
}
|
||||
script = doc.createElement("script");
|
||||
win.jihadClip = function (text) {
|
||||
win.jihadClip = null;
|
||||
sc.setClipboard(String(text), false);
|
||||
JihadToast.show("info", $L("Copied."));
|
||||
};
|
||||
script.onload = script.onerror = function () {
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
script.src = "clip/" + name;
|
||||
doc.body.appendChild(script);
|
||||
};
|
||||
|
||||
//* Paste: the clipboard is read here (only a card can) — the system's paste into a throwaway
|
||||
//* textarea — and typed into the page's focused field. The paste is not synchronous and
|
||||
//* takes anything from a few milliseconds to more than 60 (measured in the Mochi shell, same
|
||||
//* platform call), so the textarea is watched for up to 600 ms before the clipboard is
|
||||
//* called empty.
|
||||
MainAssistant.prototype.pasteIntoPage = function () {
|
||||
var doc = this.controller.document, win = this.controller.window, self = this, tries = 0;
|
||||
var ta = doc.createElement("textarea");
|
||||
ta.style.position = "absolute";
|
||||
ta.style.left = "-2000px";
|
||||
ta.style.top = "0";
|
||||
ta.value = "";
|
||||
doc.body.appendChild(ta);
|
||||
try {
|
||||
ta.select();
|
||||
if (win.PalmSystem && win.PalmSystem.paste) {
|
||||
win.PalmSystem.paste();
|
||||
} else {
|
||||
doc.execCommand("paste");
|
||||
}
|
||||
} catch (e) {
|
||||
Mojo.Log.error("[Jihad] paste: %s", e);
|
||||
}
|
||||
function look() {
|
||||
var text = ta.value;
|
||||
if (!text && ++tries < 15) {
|
||||
win.setTimeout(look, 40);
|
||||
return;
|
||||
}
|
||||
try { ta.blur(); } catch (e) {}
|
||||
if (ta.parentNode) {
|
||||
ta.parentNode.removeChild(ta);
|
||||
}
|
||||
if (text) {
|
||||
self.callBrowserAdapter("insertStringAtCursor", [String(text)]);
|
||||
} else {
|
||||
JihadToast.show("info", $L("The clipboard is empty."));
|
||||
}
|
||||
}
|
||||
win.setTimeout(look, 20);
|
||||
};
|
||||
|
||||
//* This card must run on THIS variant's adapter and no other (cavekit-mojo-ui.md R3).
|
||||
//* Mojo.Widget.WebView hard-codes the stock plugin MIME and ../models/
|
||||
//* jihad-engine-override.js rewrites it before the plugin goes live; if that ever
|
||||
|
||||
@@ -239,6 +239,7 @@ enyo.kind({
|
||||
},
|
||||
gotHistoryState: function(inBack, inForward) {
|
||||
this.canGoBack = inBack;
|
||||
this.canGoForward = inForward;
|
||||
this.$.actionbar.setCanGoBack(inBack);
|
||||
this.$.actionbar.setCanGoForward(inForward);
|
||||
},
|
||||
@@ -352,6 +353,69 @@ enyo.kind({
|
||||
this.$.context.openAtTap(inEvent, inTapInfo);
|
||||
return true;
|
||||
}
|
||||
// Anything else is the page's own long-press. Not claimed: the engine then gets the
|
||||
// hold (the page's contextmenu event; a word selected under the finger, or a text
|
||||
// field focused) and reports what it found as a "page-hold" notification, which is
|
||||
// when the menu opens — see pageHold. Several cards share one daemon and all of them
|
||||
// hear that report, so only the card that is waiting for one acts on it.
|
||||
this._pageHold = {left: inEvent.pageX, top: inEvent.pageY, at: Date.now()};
|
||||
},
|
||||
//* The engine's answer to a long-press this card passed on: {editable, selected}.
|
||||
pageHold: function(inText) {
|
||||
var h = this._pageHold, s = null;
|
||||
this._pageHold = null;
|
||||
if (!h || Date.now() - h.at > 3000) {
|
||||
return;
|
||||
}
|
||||
try { s = enyo.json.parse(inText); } catch (e) { s = null; }
|
||||
s = s || {};
|
||||
this.$.context.openForPage({left: h.left, top: h.top}, {
|
||||
editable: !!s.editable, selected: !!s.selected,
|
||||
canGoBack: !!this.canGoBack, canGoForward: !!this.canGoForward
|
||||
});
|
||||
},
|
||||
pageSelectAllClick: function() {
|
||||
this.$.view.callBrowserAdapter("selectAll");
|
||||
},
|
||||
//* Copy and Cut ask the engine for the selected text; it comes back by way of pageCopied.
|
||||
pageCopyClick: function() {
|
||||
this.$.view.callBrowserAdapter("copy");
|
||||
},
|
||||
pageCutClick: function() {
|
||||
this.$.view.callBrowserAdapter("cut");
|
||||
},
|
||||
//* The engine has written the selected text where this card may load it: a one-call
|
||||
//* script in the app's own clip/ directory (a card can load a local file from nowhere
|
||||
//* else, and the clipboard is the card's to write). `inName` is that file's name.
|
||||
pageCopied: function(inName) {
|
||||
if (!/^copy-[0-9-]+\.js$/.test(String(inName))) {
|
||||
return;
|
||||
}
|
||||
var s = document.createElement("script");
|
||||
window.jihadClip = function(inText) {
|
||||
window.jihadClip = null;
|
||||
enyo.dom.setClipboard(String(inText));
|
||||
enyo.jihadToast.show("info", $L("Copied."));
|
||||
};
|
||||
s.onload = s.onerror = function() {
|
||||
if (s.parentNode) { s.parentNode.removeChild(s); }
|
||||
};
|
||||
s.src = "clip/" + inName;
|
||||
document.body.appendChild(s);
|
||||
},
|
||||
//* Paste: the clipboard is read here (only a card can) and typed into the focused field.
|
||||
pagePasteClick: function() {
|
||||
var view = this.$.view;
|
||||
enyo.jihadGetClipboard(function(inText) {
|
||||
if (inText) {
|
||||
view.callBrowserAdapter("insertStringAtCursor", [String(inText)]);
|
||||
} else {
|
||||
enyo.jihadToast.show("info", $L("The clipboard is empty."));
|
||||
}
|
||||
});
|
||||
},
|
||||
pageBookmarkClick: function() {
|
||||
this.doAddBookmark();
|
||||
},
|
||||
contextItemClick: function(inSender, inValue, inTapInfo, inPosition) {
|
||||
if (this[inValue]) {
|
||||
|
||||
@@ -182,6 +182,10 @@ enyo.kind({
|
||||
// the sentence to show arrive together; the lists are db8 kinds this app owns.
|
||||
if (inResponse.category === "chrome-clear-bookmarks") { this.clearBookmarks(); }
|
||||
if (inResponse.category === "chrome-clear-history") { this.clearHistory(); }
|
||||
// The long-press menu's traffic with the engine: what is under the finger, and where
|
||||
// the copied text was put. Neither is a sentence to show.
|
||||
if (inResponse.category === "page-hold") { this.$.browser.pageHold(inResponse.text); return; }
|
||||
if (inResponse.category === "page-copied") { this.$.browser.pageCopied(inResponse.text); return; }
|
||||
enyo.jihadToast.show(inResponse.category, inResponse.text);
|
||||
},
|
||||
//* A failed subscription is not worth a dialog: it costs the user their status toasts and
|
||||
|
||||
@@ -44,6 +44,39 @@ enyo.kind({
|
||||
this.openNear(this.tapPosition);
|
||||
}
|
||||
},
|
||||
//* The long-press menu on anything that is not a link or an image: what a desktop browser
|
||||
//* offers on a right-click. `inState` is what the engine found under the finger —
|
||||
//* {editable, selected} — plus what this card knows: {canGoBack, canGoForward}.
|
||||
//* The values are method names on the Browser kind, as for links and images.
|
||||
openForPage: function(inPosition, inState) {
|
||||
var s = inState || {}, items = [];
|
||||
if (s.editable) {
|
||||
items.push({caption: $L("Paste"), value: "pagePasteClick"});
|
||||
if (s.selected) {
|
||||
items.push({caption: $L("Copy"), value: "pageCopyClick"});
|
||||
items.push({caption: $L("Cut"), value: "pageCutClick"});
|
||||
}
|
||||
items.push({caption: $L("Select All"), value: "pageSelectAllClick"});
|
||||
} else {
|
||||
if (s.selected) {
|
||||
items.push({caption: $L("Copy"), value: "pageCopyClick"});
|
||||
}
|
||||
items.push({caption: $L("Select All"), value: "pageSelectAllClick"});
|
||||
if (s.canGoBack) {
|
||||
items.push({caption: $L("Back"), value: "goBack"});
|
||||
}
|
||||
if (s.canGoForward) {
|
||||
items.push({caption: $L("Forward"), value: "goForward"});
|
||||
}
|
||||
items.push({caption: $L("Reload"), value: "reloadClick"});
|
||||
items.push({caption: $L("Add Bookmark"), value: "pageBookmarkClick"});
|
||||
items.push({caption: $L("Find on Page"), value: "showFind"});
|
||||
}
|
||||
this.tapPosition = inPosition;
|
||||
this.tapInfo = {};
|
||||
this.setItems(items);
|
||||
this.openNear(this.tapPosition);
|
||||
},
|
||||
makeItems: function() {
|
||||
var items;
|
||||
if (this.tapInfo.isLink) {
|
||||
|
||||
+32
-1
@@ -20,4 +20,35 @@ enyo.setClipboard = function(inText) {
|
||||
n.select();
|
||||
document.execCommand("cut");
|
||||
document.body.removeChild(n);
|
||||
}
|
||||
}
|
||||
|
||||
//* Read the clipboard and hand its text to inCallback: a throwaway textarea and the system's
|
||||
//* paste into it. enyo.dom.getClipboard does the same and reads the textarea one tick later,
|
||||
//* but the paste takes anything from a few milliseconds to more than 60 (measured in the
|
||||
//* Mochi shell, same platform call), so this watches the textarea for up to 600 ms before
|
||||
//* calling the clipboard empty.
|
||||
enyo.jihadGetClipboard = function(inCallback) {
|
||||
var n = document.createElement("textarea"), tries = 0;
|
||||
n.style.cssText = "position: absolute; height: 0px; width: 0px;";
|
||||
n.value = "";
|
||||
document.body.appendChild(n);
|
||||
enyo.keyboard.suspend();
|
||||
n.select();
|
||||
if (window.PalmSystem && PalmSystem.paste) {
|
||||
PalmSystem.paste();
|
||||
} else {
|
||||
document.execCommand("paste");
|
||||
}
|
||||
function look() {
|
||||
if (!n.value && ++tries < 15) {
|
||||
setTimeout(look, 40);
|
||||
return;
|
||||
}
|
||||
var text = n.value;
|
||||
n.blur();
|
||||
enyo.keyboard.resume();
|
||||
document.body.removeChild(n);
|
||||
inCallback(text);
|
||||
}
|
||||
setTimeout(look, 20);
|
||||
};
|
||||
@@ -74,7 +74,7 @@ luna-send -n 1 palm://com.palm.systemmanager/takeScreenShot '{"file":"/tmp/st.pn
|
||||
# /var/log/messages rotates within seconds on this device, so an offset taken before the launch
|
||||
# is not reliable: take every line of THIS scenario from both generations and keep what follows
|
||||
# its last "start".
|
||||
cat /var/log/messages.0 /var/log/messages 2>/dev/null | grep 'JIHAD-TEST\] $SC: ' | sed 's/.*\(JIHAD-[A-Z]*\]\)/\1/; s/, file:.*//' | awk '/: start\$/{buf=""} {buf=buf \$0 "\n"} END{printf "%s", buf}' | cut -c1-900
|
||||
( zcat /var/log/messages.1.gz /var/log/messages.0.gz 2>/dev/null; cat /var/log/messages.0 /var/log/messages 2>/dev/null ) | grep 'JIHAD-TEST\] $SC: ' | sed 's/.*\(JIHAD-[A-Z]*\]\)/\1/; s/, file:.*//' | awk '/: start\$/{buf=""} {buf=buf \$0 "\n"} END{printf "%s", buf}' | cut -c1-900
|
||||
DEVEOF
|
||||
put "$TMP/sf.sh" /tmp/mochi-sf.sh
|
||||
HOLD=$((WAIT + 14)) dev /tmp/mochi-sf.sh | grep 'JIHAD-' || echo "(no [JIHAD-TEST] line — hook not loaded, or the log rotated)"
|
||||
|
||||
@@ -292,6 +292,26 @@ MANIFEST
|
||||
done
|
||||
echo " printing: @riverstonerelay.net/jihad/print;1 registered (IPP, any driverless printer)"
|
||||
fi
|
||||
# --- long-press menu: the page's half (cavekit-ui-shell.md, context menu) -----------------------
|
||||
# components/jihadPageCommands.js selects the word under a long-press and carries out Select
|
||||
# All / Copy / Cut on the page. Created by the daemon at startup through its contract id.
|
||||
PAGECMD_JS="$REPO/render/goanna/components/jihadPageCommands.js"
|
||||
if [ -f "$PAGECMD_JS" ]; then
|
||||
mkdir -p "$OUT/components"
|
||||
cp -L "$PAGECMD_JS" "$OUT/components/jihadPageCommands.js"
|
||||
cat > "$OUT/components/jihad-page-commands.manifest" <<'MANIFEST'
|
||||
# Jihad Browser — the page's half of the long-press menu. Copyright 2026 NotAlexNoyle. MPL-2.0.
|
||||
component {4e0c1b7a-6a53-4a5e-9d0f-0f6a3d1b8c27} jihadPageCommands.js
|
||||
contract @riverstonerelay.net/jihad/page-commands;1 {4e0c1b7a-6a53-4a5e-9d0f-0f6a3d1b8c27}
|
||||
MANIFEST
|
||||
if ! grep -q '^manifest components/jihad-page-commands.manifest$' "$OUT/chrome.manifest" 2>/dev/null; then
|
||||
echo 'manifest components/jihad-page-commands.manifest' >> "$OUT/chrome.manifest"
|
||||
fi
|
||||
for f in components/jihadPageCommands.js components/jihad-page-commands.manifest; do
|
||||
[ -s "$OUT/$f" ] || { echo "ERROR: page-commands component file missing/empty in bundle: $f" >&2; exit 1; }
|
||||
done
|
||||
echo " long-press: @riverstonerelay.net/jihad/page-commands;1 registered (select, copy, cut)"
|
||||
fi
|
||||
# Ensure the OMTC-off pref (headless CPU paint) is present in goanna.js
|
||||
grep -q 'offmainthreadcomposition.force-disabled' "$OUT/goanna.js" 2>/dev/null || \
|
||||
echo 'pref("layers.offmainthreadcomposition.force-disabled", true);' >> "$OUT/goanna.js"
|
||||
|
||||
@@ -502,11 +502,13 @@ PROFILE=$APP/profile
|
||||
CACHE=$APP/cache
|
||||
# Two more directories the DAEMON creates at runtime inside the app's own directory, because a
|
||||
# card may load a local file only from there (LunaSysMgr's file-access rule, measured
|
||||
# 2026-10-02): bookmark thumbnails it renders on request, and the readable details of a
|
||||
# certificate while an SSL warning is open. Not in any package file list, so the installer does
|
||||
# not know them; they go with the profile.
|
||||
# 2026-10-02): bookmark thumbnails it renders on request, the readable details of a
|
||||
# certificate while an SSL warning is open, and text the long-press menu's Copy is handing to
|
||||
# the card (each such file lives fifteen seconds). Not in any package file list, so the
|
||||
# installer does not know them; they go with the profile.
|
||||
THUMBS=$APP/thumbnails
|
||||
CERTINFO=$APP/certinfo
|
||||
CLIP=$APP/clip
|
||||
log() { echo "jihad-prerm($V): $*"; }
|
||||
|
||||
# ── 0. is this a REMOVAL or an UPGRADE? ──────────────────────────────────────────────────────
|
||||
@@ -626,7 +628,7 @@ sync
|
||||
# The engine profile + cache are on cryptofs, not the rootfs, so they need no rw window — but
|
||||
# they do need to go, for the reason at $PROFILE above. Exact paths, both inside THIS variant's
|
||||
# own app directory; no glob could reach a sibling's.
|
||||
rm -rf "$PROFILE" "$CACHE" "$THUMBS" "$CERTINFO"
|
||||
rm -rf "$PROFILE" "$CACHE" "$THUMBS" "$CERTINFO" "$CLIP"
|
||||
# F-9: a failed restore-to-read-only is REPORTED, never swallowed (R8 requires `/` back to ro on
|
||||
# every exit path). It does not by itself fail the removal — the removal above already happened,
|
||||
# and the EXIT trap gets one more attempt — but it does not get to be silent either.
|
||||
@@ -651,7 +653,7 @@ fi
|
||||
# job to respawn against an app directory that no longer exists. postinst already refuses to
|
||||
# report a deploy it cannot see on disk; this is the same rule applied to the reverse operation.
|
||||
rc=0
|
||||
for f in "$SHIM" "$IMPL" "$JOB" "$SOCK" "$STATE" "$PROFILE" "$CACHE" "$THUMBS" "$CERTINFO"; do
|
||||
for f in "$SHIM" "$IMPL" "$JOB" "$SOCK" "$STATE" "$PROFILE" "$CACHE" "$THUMBS" "$CERTINFO" "$CLIP"; do
|
||||
if [ -e "$f" ]; then log "ERROR: $f still present after removal"; rc=1; fi
|
||||
done
|
||||
if [ "$rc" != 0 ]; then
|
||||
|
||||
@@ -954,24 +954,34 @@ void JihadBrowserServer::asyncCmdGetElementInfoAtPoint(YapProxy* proxy, int32_t
|
||||
(void)proxy; // TODO(T-016): route to pageFor(proxy) / GoannaRenderPage per PORT-MAP.md
|
||||
}
|
||||
|
||||
// The long-press menu's page actions. The card raises the menu and sends these; the bundled
|
||||
// component components/jihadPageCommands.js carries them out on the page.
|
||||
void JihadBrowserServer::asyncCmdSelectAll(YapProxy* proxy)
|
||||
{
|
||||
(void)proxy; // TODO(T-016): route to pageFor(proxy) / GoannaRenderPage per PORT-MAP.md
|
||||
if (auto* p = pageFor(proxy)) p->pageCommand("selectAll");
|
||||
}
|
||||
|
||||
void JihadBrowserServer::asyncCmdCopy(YapProxy* proxy, int32_t queryNum)
|
||||
{
|
||||
(void)proxy; // TODO(T-016): route to pageFor(proxy) / GoannaRenderPage per PORT-MAP.md
|
||||
// "Copy" here means: hand the selected text to the card, which owns the clipboard (it lives
|
||||
// in LunaSysMgr; a daemon has no way to write it). The reply below only releases the
|
||||
// adapter's callback record for this query — whether anything was selected is told to the
|
||||
// card separately, with the text.
|
||||
auto* p = pageFor(proxy);
|
||||
if (p) p->pageCommand("copy");
|
||||
msgCopySuccessResponse(proxy, queryNum, p != nullptr);
|
||||
}
|
||||
|
||||
void JihadBrowserServer::asyncCmdPaste(YapProxy* proxy)
|
||||
{
|
||||
(void)proxy; // TODO(T-016): route to pageFor(proxy) / GoannaRenderPage per PORT-MAP.md
|
||||
// Deliberately nothing. The clipboard is the card's to read, so a card pastes by reading it
|
||||
// and sending the text with insertStringAtCursor; this command carries no text.
|
||||
(void)proxy;
|
||||
}
|
||||
|
||||
void JihadBrowserServer::asyncCmdCut(YapProxy* proxy)
|
||||
{
|
||||
(void)proxy; // TODO(T-016): route to pageFor(proxy) / GoannaRenderPage per PORT-MAP.md
|
||||
if (auto* p = pageFor(proxy)) p->pageCommand("cut");
|
||||
}
|
||||
|
||||
void JihadBrowserServer::asyncCmdSetMouseMode(YapProxy* proxy, int32_t mode)
|
||||
|
||||
@@ -1593,6 +1593,22 @@ void BrowserPageGoanna::popupMenuSelect(const char* identifier, int selectedIdx)
|
||||
mNeedsPaint = true;
|
||||
}
|
||||
|
||||
void BrowserPageGoanna::pageCommand(const char* cmd, const std::string& extraJson) {
|
||||
if (!mPage || !cmd) return;
|
||||
// Copied text reaches the card as a file in the card's own application directory (the only
|
||||
// place a card may load a local file from — see the certificate details above for the same
|
||||
// constraint); the component is told where that is.
|
||||
std::string dir = jihad::RuntimeProfileDir();
|
||||
const size_t cut = dir.find_last_of('/');
|
||||
dir = (cut != std::string::npos && cut > 0) ? dir.substr(0, cut) + "/clip" : std::string();
|
||||
std::string json = std::string("{\"cmd\":\"") + cmd + "\",\"dir\":\"" + dir + "\"";
|
||||
if (!extraJson.empty()) { json += ","; json += extraJson; }
|
||||
json += "}";
|
||||
if (!mPage->NotifyChrome("jihad-page-command", json.c_str()))
|
||||
fprintf(stderr, "[jihad-bs] page command %s: no page window to act on\n", cmd);
|
||||
mNeedsPaint = true;
|
||||
}
|
||||
|
||||
void BrowserPageGoanna::insertStringAtCursor(const char* text) {
|
||||
// NB: never log `text` — it is user keystrokes (incl. passwords) and this stream is redirected
|
||||
// to a persistent, user-readable file on device (Jihad review F-163).
|
||||
@@ -2381,6 +2397,29 @@ void BrowserPageGoanna::pump(int msBudget, bool drainOnly) {
|
||||
: (e.type == PM_UP) ? "mouseup"
|
||||
: (e.type == PM_MOVE) ? "mousemove" : "contextmenu";
|
||||
mPage->MouseEvent(t, e.x, e.y, e.detail);
|
||||
if (e.type == PM_CONTEXTMENU) {
|
||||
// The long-press, after the page has had its contextmenu event. The card raises the
|
||||
// menu; what it offers depends on what is under the finger, which only this side
|
||||
// knows — so say. (The card claims links and images itself and never sends holdAt
|
||||
// for those, so this is plain content or a field.)
|
||||
if (!mPage || mNavGen != gen) break; // the page's handler navigated
|
||||
bool focused = false;
|
||||
const bool editable = mPage->EditableAt(e.x, e.y, &focused);
|
||||
if (editable && !focused) {
|
||||
// As a right-click does on a desktop: the field takes the focus and the caret goes
|
||||
// where the press was, so Paste has a target and the keyboard comes up.
|
||||
mPage->ClickAt(e.x, e.y, 1);
|
||||
bool efoc = false; int eft = 0, efa = 0;
|
||||
if (mPage->TakeEditorFocus(&efoc, &eft, &efa)) mSink.msgEditorFocused(efoc, eft, efa);
|
||||
if (!mPage || mNavGen != gen) break;
|
||||
}
|
||||
char extra[128];
|
||||
snprintf(extra, sizeof extra, "\"x\":%d,\"y\":%d,\"editable\":%s,\"focusedNow\":%s",
|
||||
e.x, e.y, editable ? "true" : "false", (editable && !focused) ? "true" : "false");
|
||||
fprintf(stderr, "[jihad-bs] hold at %d,%d editable=%d wasFocused=%d\n", e.x, e.y,
|
||||
(int)editable, (int)focused);
|
||||
pageCommand("hold", extra);
|
||||
}
|
||||
}
|
||||
mNeedsPaint = true;
|
||||
}
|
||||
|
||||
@@ -250,6 +250,10 @@ public:
|
||||
|
||||
public:
|
||||
void insertStringAtCursor(const char* text); // YAP: insertStringAtCursor
|
||||
// YAP: selectAll / copy / cut — the long-press menu's page actions. Done by the bundled
|
||||
// component components/jihadPageCommands.js (selection and editing are a few lines of
|
||||
// script there and pages of C++ here); this hands it the page and the command.
|
||||
void pageCommand(const char* cmd, const std::string& extraJson = std::string());
|
||||
void dragStart(int x, int y); // YAP: dragStart
|
||||
void dragProcess(int deltaX, int deltaY); // YAP: dragProcess (scroll)
|
||||
void dragEnd(int x, int y); // YAP: dragEnd
|
||||
|
||||
@@ -409,6 +409,10 @@ bool InstallDialogService() {
|
||||
// before the first request. Absent means printing answers "not available", nothing worse.
|
||||
nsCOMPtr<nsISupports> printer = do_GetService("@riverstonerelay.net/jihad/print;1");
|
||||
if (!printer) fprintf(stderr, "[jihad-bs] print: component absent, printing is unavailable\n");
|
||||
// The page's half of the long-press menu (components/jihadPageCommands.js) waits on
|
||||
// "jihad-page-command". Absent means a long-press raises no menu on plain content.
|
||||
nsCOMPtr<nsISupports> pageCmds = do_GetService("@riverstonerelay.net/jihad/page-commands;1");
|
||||
if (!pageCmds) fprintf(stderr, "[jihad-bs] long-press: component absent, no page menu\n");
|
||||
nsCOMPtr<nsISupports> xpiPrompt =
|
||||
do_GetService("@mozilla.org/addons/web-install-prompt;1");
|
||||
if (!xpiPrompt) {
|
||||
|
||||
@@ -3720,6 +3720,29 @@ void GoannaRenderPage::HitTestAt(int x, int y, std::string* json) {
|
||||
*json = out;
|
||||
}
|
||||
|
||||
bool GoannaRenderPage::EditableAt(int x, int y, bool* isFocused) {
|
||||
if (isFocused) *isFocused = false;
|
||||
if (!mChrome || !mChrome->mBrowser) return false;
|
||||
nsCOMPtr<nsIDOMWindowUtils> u = GetWindowUtils(mChrome->mBrowser);
|
||||
if (!u) return false;
|
||||
nsCOMPtr<nsIDOMElement> el;
|
||||
u->ElementFromPoint((float)x, (float)y, false, true, getter_AddRefs(el));
|
||||
if (!el) return false;
|
||||
// The press lands on the anonymous text inside an <input>; its host is the control.
|
||||
nsCOMPtr<nsIDOMElement> host = edHostControl(el);
|
||||
// contentEditable is inherited: a press on a <b> inside an editable <div> is editable.
|
||||
nsCOMPtr<nsIDOMNode> node = do_QueryInterface(host);
|
||||
for (int depth = 0; node && depth < 64; ++depth) {
|
||||
nsCOMPtr<nsIDOMElement> e = do_QueryInterface(node);
|
||||
if (e && edIsEditable(e)) {
|
||||
if (isFocused) *isFocused = (mChrome->mFocusedEditable == e);
|
||||
return true;
|
||||
}
|
||||
nsCOMPtr<nsIDOMNode> p; node->GetParentNode(getter_AddRefs(p)); node = p;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Serialize a dropdown <select>'s options to the JSON the card popup consumes, and hold a
|
||||
// reference to the element so a later popupMenuSelect can apply the choice. Returns false
|
||||
// (fall back to a normal click) if the select is empty or the DOM read fails.
|
||||
|
||||
@@ -302,6 +302,10 @@ public:
|
||||
// asyncCmdHoldAt), so this must always produce a valid document — on any miss it emits
|
||||
// {"isNull":true,...}.
|
||||
void HitTestAt(int x, int y, std::string* json);
|
||||
// Long-press: is the element at VIEWPORT (x,y) a text control or contentEditable region,
|
||||
// and if so is it already the one typing goes to? (A hold on a field that is not focused
|
||||
// focuses it first, as a right-click does on a desktop, so Paste has somewhere to go.)
|
||||
bool EditableAt(int x, int y, bool* isFocused);
|
||||
|
||||
// <select> dropdown -> card-native popup (Atlas msgPopupMenuShow/selectPopupMenuItem model,
|
||||
// impl-menupopup-2026-08-02.md). BuildSelectPopup (called from ClickAt when a dropdown
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/* 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 https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Jihad Browser — the page's half of the long-press menu: Select All, Copy, Cut.
|
||||
*
|
||||
* The card shows the menu; these three act on the PAGE, so they are done here. Requests come
|
||||
* from the daemon as an observer notification (it gets them from the card over the frozen
|
||||
* adapter commands selectAll / copy / cut, which were no-op stubs until 2026-10-05):
|
||||
*
|
||||
* "jihad-page-command" subject = the content window, data = {"cmd": …, "dir": …}
|
||||
* hold a long-press landed at viewport x,y (`editable`: on a text field;
|
||||
* `focusedNow`: which the press itself has just focused).
|
||||
* On text it selects the word under the finger — unless the press is
|
||||
* inside what is already selected, which is then kept, as a right-click
|
||||
* keeps it. Answers with the notification "page-hold",
|
||||
* {"editable":…,"selected":…}; the card builds its menu from that.
|
||||
* selectAll select everything in what the last press was on: the field, or the page
|
||||
* copy hand the text selected there to the card
|
||||
* cut the same, then delete it (in a field; elsewhere it is a copy)
|
||||
*
|
||||
* HOW THE TEXT REACHES THE CARD. The system clipboard belongs to LunaSysMgr, and only a card
|
||||
* can write it. The text cannot ride the hit-test reply (the platform's HitTest.schema
|
||||
* forbids extra keys) nor the notification channel (any app may subscribe to that, and it is
|
||||
* cut at 200 characters). So it is written to a file in the card's own application
|
||||
* directory — the one place a card may load a local file from — as a one-call script, and
|
||||
* the card is told its NAME over the notification channel:
|
||||
*
|
||||
* <appdir>/clip/copy-<n>.js if(window.jihadClip){window.jihadClip("…");}
|
||||
* notification category "page-copied", text "copy-<n>.js"
|
||||
*
|
||||
* The card loads it with a script tag, puts the text on the clipboard and says "Copied.".
|
||||
* The file is removed fifteen seconds later (and the directory emptied at start-up); the
|
||||
* clipboard itself is readable by every app on the device, so this exposes nothing the copy
|
||||
* was not already going to.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
const MAX_COPY = 1024 * 1024; // characters; a page is text, not a file transfer
|
||||
let gSeq = 0;
|
||||
let gTimers = [];
|
||||
|
||||
function log(s) {
|
||||
try { Services.console.logStringMessage("page: " + s); } catch (e) {}
|
||||
}
|
||||
|
||||
function notify(category, text) {
|
||||
try {
|
||||
let bag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(Ci.nsIWritablePropertyBag2);
|
||||
bag.setPropertyAsAString("category", category);
|
||||
bag.setPropertyAsAString("text", String(text));
|
||||
Services.obs.notifyObservers(bag, "jihad-notify", null);
|
||||
} catch (e) { log("notify failed: " + e); }
|
||||
}
|
||||
|
||||
//* The window that has the focus inside `win` (frames followed), and its focused element.
|
||||
function focusOf(win) {
|
||||
let w = win, el = null;
|
||||
for (let depth = 0; depth < 8 && w && w.document; depth++) {
|
||||
el = w.document.activeElement;
|
||||
let inner = null;
|
||||
try { inner = el && el.contentWindow; } catch (e) { inner = null; }
|
||||
if (!inner) { break; }
|
||||
w = inner;
|
||||
}
|
||||
return {win: w, el: el};
|
||||
}
|
||||
|
||||
function isTextField(el) {
|
||||
if (!el || !el.localName) { return false; }
|
||||
let n = el.localName.toLowerCase();
|
||||
return n === "textarea" || n === "input";
|
||||
}
|
||||
|
||||
function selectedText(f) {
|
||||
// A field keeps its own selection; types without one (checkbox, number in some builds)
|
||||
// throw on selectionStart, and those have nothing to copy.
|
||||
if (isTextField(f.el)) {
|
||||
try {
|
||||
let a = f.el.selectionStart, b = f.el.selectionEnd;
|
||||
if (typeof a === "number" && typeof b === "number" && b > a) {
|
||||
return String(f.el.value).substring(a, b);
|
||||
}
|
||||
} catch (e) {}
|
||||
return "";
|
||||
}
|
||||
try { return String(f.win.getSelection() || ""); } catch (e) { return ""; }
|
||||
}
|
||||
|
||||
//* What the last long-press was on: {win: weak reference to its window, editable}. The menu's
|
||||
//* commands act on THAT — Select All after a press on the page's text selects the page even
|
||||
//* while a text field has the focus, and Copy copies the page's selection, not the field's.
|
||||
let gHold = null;
|
||||
|
||||
function plainSelection(w) {
|
||||
try {
|
||||
let sel = w.getSelection();
|
||||
return (sel && !sel.isCollapsed) ? String(sel) : "";
|
||||
} catch (e) { return ""; }
|
||||
}
|
||||
|
||||
//* True when one of the selection's boxes is under (or within a fingertip's slop of) x,y.
|
||||
function selectionIsAt(sel, x, y) {
|
||||
const SLOP = 12;
|
||||
for (let i = 0; i < sel.rangeCount; i++) {
|
||||
let rects = sel.getRangeAt(i).getClientRects();
|
||||
for (let k = 0; k < rects.length; k++) {
|
||||
let r = rects[k];
|
||||
if (x >= r.left - SLOP && x <= r.right + SLOP && y >= r.top - SLOP && y <= r.bottom + SLOP) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//* The long-press on plain content. Returns true when something is selected afterwards.
|
||||
function holdSelect(win, x, y) {
|
||||
let w = win;
|
||||
for (let depth = 0; depth < 8; depth++) {
|
||||
let el = w.document.elementFromPoint(x, y), inner = null;
|
||||
try { inner = el && el.contentWindow; } catch (e) { inner = null; }
|
||||
if (!inner || !inner.document) { break; }
|
||||
let r = el.getBoundingClientRect();
|
||||
x -= r.left + el.clientLeft;
|
||||
y -= r.top + el.clientTop;
|
||||
w = inner;
|
||||
}
|
||||
gHold = {win: Cu.getWeakReference(w), editable: false};
|
||||
let pos = null, sel = null;
|
||||
try { pos = w.document.caretPositionFromPoint(x, y); } catch (e) { pos = null; }
|
||||
try {
|
||||
sel = w.getSelection();
|
||||
if (sel && !sel.isCollapsed && pos && pos.offsetNode) {
|
||||
for (let i = 0; i < sel.rangeCount; i++) {
|
||||
if (sel.getRangeAt(i).isPointInRange(pos.offsetNode, pos.offset)) { return true; }
|
||||
}
|
||||
}
|
||||
// Not inside it: this press replaces it. (The engine's own call ADDS a range, which
|
||||
// left every word ever pressed highlighted at once.)
|
||||
if (sel) { sel.removeAllRanges(); }
|
||||
} catch (e) {}
|
||||
try {
|
||||
let utils = w.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
|
||||
utils.selectAtPoint(x, y, Ci.nsIDOMWindowUtils.SELECT_WORDNOSPACE);
|
||||
} catch (e) { log("hold: selectAtPoint threw: " + e); }
|
||||
try {
|
||||
sel = w.getSelection();
|
||||
// A press on the last half-letter of a word puts the caret at the word's END, and the
|
||||
// engine then takes that word and the next one ("bravo charlie", measured). One press
|
||||
// is one word: when two came back, keep the one the caret is in or at the end of.
|
||||
if (/\S\s+\S/.test(plainSelection(w)) && pos && pos.offsetNode && pos.offsetNode.nodeType === 3) {
|
||||
let d = pos.offsetNode.data, a = pos.offset, b = pos.offset;
|
||||
while (a > 0 && /\S/.test(d.charAt(a - 1))) { a--; }
|
||||
while (b < d.length && /\S/.test(d.charAt(b))) { b++; }
|
||||
if (b > a) {
|
||||
let r = w.document.createRange();
|
||||
r.setStart(pos.offsetNode, a);
|
||||
r.setEnd(pos.offsetNode, b);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
}
|
||||
}
|
||||
// A press on empty space selects the nearest word, wherever that is. Nothing was
|
||||
// pressed, so nothing is selected.
|
||||
if (sel && !sel.isCollapsed && !selectionIsAt(sel, x, y)) { sel.removeAllRanges(); }
|
||||
} catch (e) { log("hold: narrowing threw: " + e); }
|
||||
return /\S/.test(plainSelection(w));
|
||||
}
|
||||
|
||||
//* What a command acts on: {field: focusOf() result} for a text field or editable region,
|
||||
//* or {doc: window} for a page's own text.
|
||||
function target(win) {
|
||||
let f = focusOf(win);
|
||||
if (gHold) {
|
||||
let hw = null;
|
||||
try { hw = gHold.win.get(); } catch (e) { hw = null; }
|
||||
try {
|
||||
if (hw && !hw.closed && hw.document) { return gHold.editable ? {field: f} : {doc: hw}; }
|
||||
} catch (e) {}
|
||||
}
|
||||
// No press to go by (a command from somewhere other than the long-press menu).
|
||||
return isTextField(f.el) ? {field: f} : {doc: f.win};
|
||||
}
|
||||
|
||||
function selectAll(t) {
|
||||
if (t.doc) {
|
||||
let d = t.doc.document;
|
||||
t.doc.getSelection().selectAllChildren(d.body || d.documentElement);
|
||||
return;
|
||||
}
|
||||
if (isTextField(t.field.el)) {
|
||||
try { t.field.el.select(); return; } catch (e) {}
|
||||
}
|
||||
docShellOf(t.field.win).doCommand("cmd_selectAll");
|
||||
}
|
||||
|
||||
function deleteSelection(f) {
|
||||
if (isTextField(f.el)) {
|
||||
try {
|
||||
let a = f.el.selectionStart, b = f.el.selectionEnd;
|
||||
if (typeof a === "number" && b > a) {
|
||||
f.el.setRangeText("", a, b, "end");
|
||||
// What typing would have told the page.
|
||||
let ev = f.el.ownerDocument.createEvent("Event");
|
||||
ev.initEvent("input", true, false);
|
||||
f.el.dispatchEvent(ev);
|
||||
}
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
docShellOf(f.win).doCommand("cmd_delete");
|
||||
}
|
||||
|
||||
function docShellOf(win) {
|
||||
return win.QueryInterface(Ci.nsIInterfaceRequestor)
|
||||
.getInterface(Ci.nsIWebNavigation)
|
||||
.QueryInterface(Ci.nsIDocShell);
|
||||
}
|
||||
|
||||
function writeClip(dir, text) {
|
||||
let d = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
|
||||
d.initWithPath(dir);
|
||||
if (!d.exists()) { d.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); }
|
||||
let name = "copy-" + Date.now() + "-" + (++gSeq) + ".js";
|
||||
let f = d.clone();
|
||||
f.append(name);
|
||||
// JSON is a JavaScript string literal except for two line separators, which end a line in
|
||||
// a script and must be escaped there.
|
||||
let lit = JSON.stringify(text).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
||||
let body = "if(window.jihadClip){window.jihadClip(" + lit + ");}\n";
|
||||
let out = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(Ci.nsIFileOutputStream);
|
||||
out.init(f, 0x02 | 0x08 | 0x20, 0o644, 0); // write | create | truncate
|
||||
let conv = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance(Ci.nsIConverterOutputStream);
|
||||
conv.init(out, "UTF-8", 0, 0);
|
||||
conv.writeString(body);
|
||||
conv.close();
|
||||
// Not kept: the card reads it within a second.
|
||||
let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
|
||||
timer.initWithCallback({notify: function() {
|
||||
try { if (f.exists()) { f.remove(false); } } catch (e) {}
|
||||
let i = gTimers.indexOf(timer);
|
||||
if (i >= 0) { gTimers.splice(i, 1); }
|
||||
}}, 15000, Ci.nsITimer.TYPE_ONE_SHOT);
|
||||
gTimers.push(timer);
|
||||
return name;
|
||||
}
|
||||
|
||||
//* Empty the clip directory: whatever is in it was written by a daemon that is gone.
|
||||
function sweep(dir) {
|
||||
try {
|
||||
let d = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
|
||||
d.initWithPath(dir);
|
||||
if (!d.exists() || !d.isDirectory()) { return; }
|
||||
let it = d.directoryEntries;
|
||||
while (it.hasMoreElements()) {
|
||||
let f = it.getNext().QueryInterface(Ci.nsIFile);
|
||||
if (/^copy-[0-9-]+\.js$/.test(f.leafName)) { try { f.remove(false); } catch (e) {} }
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
let gSwept = false;
|
||||
|
||||
function JihadPageCommands() {
|
||||
Services.obs.addObserver(this, "jihad-page-command", false);
|
||||
}
|
||||
|
||||
JihadPageCommands.prototype = {
|
||||
classID: Components.ID("{4e0c1b7a-6a53-4a5e-9d0f-0f6a3d1b8c27}"),
|
||||
QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
|
||||
|
||||
observe: function(aSubject, aTopic, aData) {
|
||||
if (aTopic !== "jihad-page-command") { return; }
|
||||
let req = null;
|
||||
try { req = JSON.parse(aData || "{}"); } catch (e) { req = {}; }
|
||||
let win = aSubject;
|
||||
if (!win || !win.document) { return; }
|
||||
try {
|
||||
if (req.dir && !gSwept) { gSwept = true; sweep(req.dir); }
|
||||
if (req.cmd === "hold") {
|
||||
let x = Number(req.x) || 0, y = Number(req.y) || 0, selected = false;
|
||||
if (req.editable) {
|
||||
let f = focusOf(win);
|
||||
gHold = {win: Cu.getWeakReference(f.win), editable: true};
|
||||
// A field the press has just focused: the caret goes where the finger is, as a
|
||||
// click puts it (focusing alone leaves it at the start, and Paste would land there).
|
||||
if (req.focusedNow && isTextField(f.el)) {
|
||||
try {
|
||||
let pos = f.win.document.caretPositionFromPoint(x, y);
|
||||
if (pos && pos.offsetNode === f.el) { f.el.setSelectionRange(pos.offset, pos.offset); }
|
||||
} catch (e) {}
|
||||
}
|
||||
selected = !!selectedText(f);
|
||||
} else {
|
||||
selected = holdSelect(win, x, y);
|
||||
}
|
||||
notify("page-hold", JSON.stringify({editable: !!req.editable, selected: selected}));
|
||||
log("hold: editable=" + !!req.editable + " selected=" + selected);
|
||||
} else if (req.cmd === "selectAll") {
|
||||
selectAll(target(win));
|
||||
} else if (req.cmd === "copy" || req.cmd === "cut") {
|
||||
let t = target(win);
|
||||
let text = t.doc ? plainSelection(t.doc) : selectedText(t.field);
|
||||
if (!text) { notify("page-copy-empty", "Nothing is selected."); return; }
|
||||
if (text.length > MAX_COPY) { text = text.substr(0, MAX_COPY); }
|
||||
if (!req.dir) { notify("page-copy-empty", "Copying is not available."); return; }
|
||||
notify("page-copied", writeClip(req.dir, text));
|
||||
log(req.cmd + ": " + text.length + " characters handed to the card");
|
||||
// Cut on a page's own text is a copy: there is nothing there to delete.
|
||||
if (req.cmd === "cut" && t.field) { deleteSelection(t.field); }
|
||||
}
|
||||
} catch (e) {
|
||||
log(String(req.cmd) + " threw: " + e);
|
||||
if (req.cmd === "copy" || req.cmd === "cut") {
|
||||
notify("page-copy-empty", "The text could not be copied.");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([JihadPageCommands]);
|
||||
@@ -66,6 +66,28 @@
|
||||
wait = 1200;
|
||||
} else if (st === "?") { // report the state now
|
||||
state("now", function() {});
|
||||
} else if (st.charAt(0) === "h") { // hX,Y: a long-press at view X,Y, from the point the
|
||||
// card has passed it on: the card is marked as waiting, the engine is sent the hold.
|
||||
// NOT a finger: it skips the adapter's own gesture timing and hit test.
|
||||
var hs = st.slice(1).split(",");
|
||||
app.$.browser._pageHold = {left: parseInt(hs[0], 10), top: parseInt(hs[1], 10) + 82, at: Date.now()};
|
||||
view.callBrowserAdapter("mouseHoldAt", [parseInt(hs[0], 10), parseInt(hs[1], 10)]);
|
||||
log(name, "hold " + st.slice(1));
|
||||
} else if (st.charAt(0) === "m") { // m: list the open long-press menu; mN: choose entry N
|
||||
var cm = app.$.browser.$.context, its = cm.items || [], caps = [];
|
||||
for (var q = 0; q < its.length; q++) { caps.push(its[q].caption); }
|
||||
log(name, "menu open=" + Boolean(cm.isOpen) + " items=[" + caps.join(" | ") + "]");
|
||||
if (st.length > 1 && its[parseInt(st.slice(1), 10)]) {
|
||||
var it = its[parseInt(st.slice(1), 10)];
|
||||
log(name, "menu pick [" + it.caption + "]");
|
||||
app.$.browser.contextItemClick(cm, it.value, cm.tapInfo, cm.tapPosition); // what a tap on the row does
|
||||
cm.close();
|
||||
}
|
||||
} else if (st === "c") { // what is on the clipboard now
|
||||
enyo.jihadGetClipboard(function(t) { log(name, "clipboard=[" + String(t).slice(0, 160) + "] length=" + String(t).length); });
|
||||
} else if (st.charAt(0) === "k") { // kTEXT: put TEXT on the clipboard (to have something to paste)
|
||||
enyo.dom.setClipboard(st.slice(1));
|
||||
log(name, "clipboard set");
|
||||
} else if (st.charAt(0) === "s") {
|
||||
// The framework's own drop-down (enyo.WebView's PopupList): choose and close,
|
||||
// which is what a tap on one of its rows does.
|
||||
|
||||
@@ -57,6 +57,28 @@
|
||||
wait = 1200;
|
||||
} else if (st === "?") { // report the state now
|
||||
state("now", function() {});
|
||||
} else if (st.charAt(0) === "h") { // hX,Y: a long-press at view X,Y, from the point the
|
||||
// card has passed it on: the card is marked as waiting, the engine is sent the hold.
|
||||
// NOT a finger: it skips the adapter's own gesture timing and hit test.
|
||||
var hs = st.slice(1).split(",");
|
||||
app.pageHoldPending();
|
||||
app.$.view.callBrowserAdapter("mouseHoldAt", [parseInt(hs[0], 10), parseInt(hs[1], 10)]);
|
||||
log(name, "hold " + st.slice(1));
|
||||
} else if (st.charAt(0) === "m") { // m: list the open long-press sheet; mN: choose entry N
|
||||
var its = app.$.sheetItems.getClientControls(), caps = [];
|
||||
for (var q = 0; q < its.length; q++) { caps.push(its[q].getContent()); }
|
||||
log(name, "sheet showing=" + app.$.sheet.getShowing() + " title=[" + app.$.sheetTitle.getContent() + "] items=[" + caps.join(" | ") + "]");
|
||||
if (st.length > 1 && its[parseInt(st.slice(1), 10)]) {
|
||||
log(name, "sheet pick [" + its[parseInt(st.slice(1), 10)].getContent() + "]");
|
||||
app.sheetPick(its[parseInt(st.slice(1), 10)]); // what a tap on the row does
|
||||
}
|
||||
} else if (st === "p") { // the menu's Paste, without the menu
|
||||
log(name, "paste (direct)");
|
||||
app.pagePaste();
|
||||
} else if (st === "c") { // what is on the clipboard now
|
||||
enyo.jihad.getClipboard(function(t) { log(name, "clipboard=[" + String(t).slice(0, 160) + "] length=" + String(t).length); });
|
||||
} else if (st.charAt(0) === "k") { // kTEXT: put TEXT on the clipboard (to have something to paste)
|
||||
log(name, "clipboard set=" + enyo.jihad.setClipboard(st.slice(1)));
|
||||
} else if (st.charAt(0) === "s") {
|
||||
var want = parseInt(st.slice(1), 10), rows = app.$.selectBox.getClientControls(), row = null;
|
||||
for (var k = 0; k < rows.length; k++) { if (rows[k]._optIndex === want) { row = rows[k]; } }
|
||||
|
||||
Reference in new issue
Block a user