diff --git a/Makefile b/Makefile
index 70021af..00f5ac5 100644
--- a/Makefile
+++ b/Makefile
@@ -25,6 +25,7 @@ SOURCES = src/mm.js \
src/format.freemind.js \
src/format.mma.js \
src/format.mup.js \
+ src/format.plaintext.js \
src/backend.js \
src/backend.local.js \
src/backend.webdav.js \
diff --git a/examples/features.mymind b/examples/features.mymind
index e4349e2..c0325fa 100644
--- a/examples/features.mymind
+++ b/examples/features.mymind
@@ -1,7 +1,7 @@
{
"root": {
"id": "ujfdpxoz",
- "text": "My Mind\nFeatures",
+ "text": "My Mind
Features",
"layout": "map",
"children": [
{
diff --git a/my-mind.js b/my-mind.js
index 5ef1f6d..a74b096 100644
--- a/my-mind.js
+++ b/my-mind.js
@@ -462,6 +462,19 @@ MM.Item.prototype.clone = function() {
return this.constructor.fromJSON(data);
}
+MM.Item.prototype.select = function() {
+ this._dom.node.classList.add("current");
+ this.getMap().ensureItemVisibility(this);
+ MM.Clipboard.focus(); /* going to mode 2c */
+ MM.publish("item-select", this);
+}
+
+MM.Item.prototype.deselect = function() {
+ /* we were in 2b; finish that via 3b */
+ if (MM.App.editing) { MM.Command.Finish.execute(); }
+ this._dom.node.classList.remove("current");
+}
+
MM.Item.prototype.update = function(doNotRecurse) {
var map = this.getMap();
if (!map || !map.isVisible()) { return this; }
@@ -497,7 +510,7 @@ MM.Item.prototype.updateSubtree = function(isSubChild) {
}
MM.Item.prototype.setText = function(text) {
- this._dom.text.innerHTML = text.replace(/\n/g, "
");
+ this._dom.text.innerHTML = text;
this._findLinks(this._dom.text);
return this.update();
}
@@ -507,7 +520,7 @@ MM.Item.prototype.getId = function() {
}
MM.Item.prototype.getText = function() {
- return this._dom.text.innerHTML.replace(/
/g, "\n");
+ return this._dom.text.innerHTML;
}
MM.Item.prototype.collapse = function() {
@@ -647,7 +660,7 @@ MM.Item.prototype.insertChild = function(child, index) {
if (!child) {
child = new MM.Item();
newChild = true;
- } else if (child.getParent()) {
+ } else if (child.getParent() && child.getParent().removeChild) { /* only when the child has non-map parent */
child.getParent().removeChild(child);
}
@@ -685,7 +698,7 @@ MM.Item.prototype.removeChild = function(child) {
MM.Item.prototype.startEditing = function() {
this._oldText = this.getText();
this._dom.text.contentEditable = true;
- this._dom.text.focus();
+ this._dom.text.focus(); /* switch to 2b */
document.execCommand("styleWithCSS", null, false);
this._dom.text.addEventListener("input", this);
@@ -706,6 +719,9 @@ MM.Item.prototype.stopEditing = function() {
this._oldText = "";
this.update(); /* text changed */
+
+ MM.Clipboard.focus();
+
return result;
}
@@ -720,7 +736,7 @@ MM.Item.prototype.handleEvent = function(e) {
if (e.keyCode == 9) { e.preventDefault(); } /* TAB has a special meaning in this app, do not use it to change focus */
break;
- case "blur":
+ case "blur": /* 3d */
MM.Command.Finish.execute();
break;
@@ -1056,7 +1072,7 @@ MM.Map.prototype.getRoot = function() {
MM.Map.prototype.getName = function() {
var name = this._root.getText();
- return name.replace(/\n/g, " ").replace(/<.*?>/g, "").trim();
+ return MM.Format.br2nl(name).replace(/\n/g, " ").replace(/<.*?>/g, "").trim();
}
MM.Map.prototype.getId = function() {
@@ -1133,6 +1149,7 @@ MM.Keyboard.init = function() {
}
MM.Keyboard.handleEvent = function(e) {
+ /* mode 2a: ignore keyboard when the activeElement resides somewhere inside of the UI pane */
var node = document.activeElement;
while (node && node != document) {
if (node.classList.contains("ui")) { return; }
@@ -1146,7 +1163,7 @@ MM.Keyboard.handleEvent = function(e) {
var keys = command.keys;
for (var j=0;j forbidden */
+ if (item == sourceItem) { return; } /* moving to a child => forbidden */
item = item.getParent();
}
- var action = new MM.Action.MoveItem(this._data, targetItem);
+ var action = new MM.Action.MoveItem(sourceItem, targetItem);
MM.App.action(action);
this._endCut();
break;
case "copy":
- var action = new MM.Action.AppendItem(targetItem, this._data.clone());
+ var action = new MM.Action.AppendItem(targetItem, sourceItem.clone());
MM.App.action(action);
break;
}
+}
+
+MM.Clipboard._pastePlaintext = function(plaintext, targetItem) {
+ if (this._mode == "cut") { this._endCut(); } /* external paste => abort cutting */
+
+ var json = MM.Format.Plaintext.from(plaintext);
+ var map = MM.Map.fromJSON(json);
+ var root = map.getRoot();
+ if (root.getText()) {
+ var action = new MM.Action.AppendItem(targetItem, root);
+ MM.App.action(action);
+ } else {
+ var actions = root.getChildren().map(function(item) {
+ return new MM.Action.AppendItem(targetItem, item);
+ });
+ var action = new MM.Action.Multi(actions);
+ MM.App.action(action);
+ }
}
MM.Clipboard.cut = function(sourceItem) {
this._endCut();
- this._data = sourceItem;
+ this._item = sourceItem;
+ this._item.getDOM().node.classList.add("cut");
this._mode = "cut";
- var node = this._data.getDOM().node;
- node.classList.add("cut");
+ this._expose();
+}
+
+/**
+ * Expose plaintext data to the textarea to be copied to system clipboard. Clear afterwards.
+ */
+MM.Clipboard._expose = function() {
+ var json = this._item.toJSON();
+ var plaintext = MM.Format.Plaintext.to(json);
+ this._node.value = plaintext;
+ this._node.selectionStart = 0;
+ this._node.selectionEnd = this._node.value.length;
+ setTimeout(this._empty.bind(this), this._delay);
+}
+
+MM.Clipboard._empty = function() {
+ /* safari needs a non-empty selection in order to actually perfrom a real copy on cmd+c */
+ this._node.value = "\n";
+ this._node.selectionStart = 0;
+ this._node.selectionEnd = this._node.value.length;
}
MM.Clipboard._endCut = function() {
if (this._mode != "cut") { return; }
- var node = this._data.getDOM().node;
- node.classList.remove("cut");
-
- this._data = null;
+ this._item.getDOM().node.classList.remove("cut");
+ this._item = null;
this._mode = "";
}
MM.Menu = {
@@ -1499,6 +1596,7 @@ MM.Menu = {
MM.Command = Object.create(MM.Repo, {
keys: {value: []},
editMode: {value: false},
+ prevent: {value: true}, /* prevent default keyboard action? */
label: {value: ""}
});
@@ -1742,7 +1840,11 @@ MM.Command.Pan.handleEvent = function(e) {
MM.Command.Copy = Object.create(MM.Command, {
label: {value: "Copy"},
- keys: {value: [{keyCode: "C".charCodeAt(0), ctrlKey:true}]}
+ prevent: {value: false},
+ keys: {value: [
+ {keyCode: "C".charCodeAt(0), ctrlKey:true},
+ {keyCode: "C".charCodeAt(0), metaKey:true}
+ ]}
});
MM.Command.Copy.execute = function() {
MM.Clipboard.copy(MM.App.current);
@@ -1750,7 +1852,11 @@ MM.Command.Copy.execute = function() {
MM.Command.Cut = Object.create(MM.Command, {
label: {value: "Cut"},
- keys: {value: [{keyCode: "X".charCodeAt(0), ctrlKey:true}]}
+ prevent: {value: false},
+ keys: {value: [
+ {keyCode: "X".charCodeAt(0), ctrlKey:true},
+ {keyCode: "X".charCodeAt(0), metaKey:true}
+ ]}
});
MM.Command.Cut.execute = function() {
MM.Clipboard.cut(MM.App.current);
@@ -1758,7 +1864,11 @@ MM.Command.Cut.execute = function() {
MM.Command.Paste = Object.create(MM.Command, {
label: {value: "Paste"},
- keys: {value: [{keyCode: "V".charCodeAt(0), ctrlKey:true}]}
+ prevent: {value: false},
+ keys: {value: [
+ {keyCode: "V".charCodeAt(0), ctrlKey:true},
+ {keyCode: "V".charCodeAt(0), metaKey:true}
+ ]}
});
MM.Command.Paste.execute = function() {
MM.Clipboard.paste(MM.App.current);
@@ -1876,7 +1986,7 @@ MM.Command.Strikethrough = Object.create(MM.Command.Style, {
MM.Command.Value = Object.create(MM.Command, {
label: {value: "Set value"},
- keys: {value: [{charCode: "v".charCodeAt(0), ctrlKey:false}]}
+ keys: {value: [{charCode: "v".charCodeAt(0), ctrlKey:false, metaKey:false}]}
});
MM.Command.Value.execute = function() {
var item = MM.App.current;
@@ -1915,7 +2025,7 @@ MM.Command.No.execute = function() {
MM.Command.Computed = Object.create(MM.Command, {
label: {value: "Computed"},
- keys: {value: [{charCode: "c".charCodeAt(0), ctrlKey:false}]}
+ keys: {value: [{charCode: "c".charCodeAt(0), ctrlKey:false, metaKey:false}]}
});
MM.Command.Computed.execute = function() {
var item = MM.App.current;
@@ -2700,6 +2810,14 @@ MM.Format.getByMime = function(mime) {
MM.Format.to = function(data) {}
MM.Format.from = function(data) {}
+
+MM.Format.nl2br = function(str) {
+ return str.replace(/\n/g, "
");
+}
+
+MM.Format.br2nl = function(str) {
+ return str.replace(/
/g, "\n");
+}
MM.Format.JSON = Object.create(MM.Format, {
id: {value: "json"},
label: {value: "Native (JSON)"},
@@ -2708,7 +2826,7 @@ MM.Format.JSON = Object.create(MM.Format, {
});
MM.Format.JSON.to = function(data) {
- return JSON.stringify(data, null, 2) + "\n";
+ return JSON.stringify(data, null, "\t") + "\n";
}
MM.Format.JSON.from = function(data) {
@@ -2762,7 +2880,7 @@ MM.Format.FreeMind._serializeItem = function(doc, json) {
MM.Format.FreeMind._serializeAttributes = function(doc, json) {
var elm = doc.createElement("node");
- elm.setAttribute("TEXT", json.text);
+ elm.setAttribute("TEXT", MM.Format.br2nl(json.text));
elm.setAttribute("ID", json.id);
if (json.side) { elm.setAttribute("POSITION", json.side); }
@@ -2788,7 +2906,7 @@ MM.Format.FreeMind._parseNode = function(node, parent) {
MM.Format.FreeMind._parseAttributes = function(node, parent) {
var json = {
children: [],
- text: node.getAttribute("TEXT") || "",
+ text: MM.Format.nl2br(node.getAttribute("TEXT") || ""),
id: node.getAttribute("ID")
};
@@ -2834,7 +2952,7 @@ MM.Format.MMA = Object.create(MM.Format.FreeMind, {
MM.Format.MMA._parseAttributes = function(node, parent) {
var json = {
children: [],
- text: node.getAttribute("title") || "",
+ text: MM.Format.nl2br(node.getAttribute("title") || ""),
shape: "box"
};
@@ -2864,7 +2982,7 @@ MM.Format.MMA._parseAttributes = function(node, parent) {
MM.Format.MMA._serializeAttributes = function(doc, json) {
var elm = doc.createElement("node");
- elm.setAttribute("title", json.text);
+ elm.setAttribute("title", MM.Format.br2nl(json.text));
elm.setAttribute("expand", json.collapsed ? "false" : "true");
if (json.side) { elm.setAttribute("direction", json.side == "left" ? "0" : "1"); }
@@ -2903,7 +3021,7 @@ MM.Format.Mup.from = function(data) {
MM.Format.Mup._MupToMM = function(item) {
var json = {
- text: item.title,
+ text: MM.Format.nl2br(item.title),
id: item.id,
shape: "box"
}
@@ -2941,7 +3059,7 @@ MM.Format.Mup._MupToMM = function(item) {
MM.Format.Mup._MMtoMup = function(item, side) {
var result = {
id: item.id,
- title: item.text,
+ title: MM.Format.br2nl(item.text),
attr: {}
}
if (item.color) {
@@ -2967,6 +3085,92 @@ MM.Format.Mup._MMtoMup = function(item, side) {
return result;
}
+MM.Format.Plaintext = Object.create(MM.Format, {
+ id: {value: "plaintext"},
+ label: {value: "Plain text"},
+ extension: {value: "txt"},
+ mime: {value: "application/vnd.mymind+txt"}
+});
+
+/**
+ * Can serialize also a sub-tree
+ */
+MM.Format.Plaintext.to = function(data) {
+ return this._serializeItem(data.root || data);
+}
+
+MM.Format.Plaintext.from = function(data) {
+ var lines = data.split("\n").filter(function(line) {
+ return line.match(/\S/);
+ });
+
+ var items = this._parseItems(lines);
+
+ if (items.length == 1) {
+ var result = {
+ root: items[0]
+ }
+ } else {
+ var result = {
+ root: {
+ text: "",
+ children: items
+ }
+ }
+ }
+ result.root.layout = "map";
+
+ return result;
+}
+
+MM.Format.Plaintext._serializeItem = function(item, depth) {
+ depth = depth || 0;
+
+ var lines = (item.children || []) .map(function(child) {
+ return this._serializeItem(child, depth+1);
+ }, this);
+
+ var prefix = new Array(depth+1).join("\t");
+ lines.unshift(prefix + item.text.replace(/\n/g, ""));
+
+ return lines.join("\n") + (depth ? "" : "\n");
+}
+
+
+MM.Format.Plaintext._parseItems = function(lines) {
+ var items = [];
+ if (!lines.length) { return items; }
+ var firstPrefix = this._parsePrefix(lines[0]);
+
+ var currentItem = null;
+ var childLines = [];
+
+ /* finalize a block of sub-children by converting them to items and appending */
+ var convertChildLinesToChildren = function() {
+ if (!currentItem || !childLines.length) { return; }
+ var children = this._parseItems(childLines);
+ if (children.length) { currentItem.children = children; }
+ childLines = [];
+ }
+
+ lines.forEach(function(line, index) {
+ if (this._parsePrefix(line) == firstPrefix) { /* new top-level item! */
+ convertChildLinesToChildren.call(this); /* finalize previous item */
+ currentItem = {text:line.match(/^\s*(.*)/)[1]};
+ items.push(currentItem);
+ } else { /* prepare as a future child */
+ childLines.push(line);
+ }
+ }, this);
+
+ convertChildLinesToChildren.call(this);
+
+ return items;
+}
+
+MM.Format.Plaintext._parsePrefix = function(line) {
+ return line.match(/^\s*/)[0];
+}
MM.Backend = Object.create(MM.Repo);
/**
@@ -3492,7 +3696,6 @@ MM.Backend.GDrive._auth = function(forceUI) {
}
MM.UI = function() {
this._node = document.querySelector(".ui");
- this._node.addEventListener("click", this);
this._toggle = this._node.querySelector("#toggle");
@@ -3502,8 +3705,11 @@ MM.UI = function() {
this._value = new MM.UI.Value();
this._status = new MM.UI.Status();
- MM.subscribe("item-change", this);
MM.subscribe("item-select", this);
+ MM.subscribe("item-change", this);
+
+ this._node.addEventListener("click", this);
+ this._node.addEventListener("change", this);
this.toggle();
}
@@ -3521,22 +3727,29 @@ MM.UI.prototype.handleMessage = function(message, publisher) {
}
MM.UI.prototype.handleEvent = function(e) {
- /* blur to return focus back to app commands */
- if (e.target.nodeName.toLowerCase() != "select") { e.target.blur(); }
+ switch (e.type) {
+ case "click":
+ if (e.target.nodeName.toLowerCase() != "select") { MM.Clipboard.focus(); } /* focus the clipboard (2c) */
- if (e.target == this._toggle) {
- this.toggle();
- return;
- }
-
- var node = e.target;
- while (node != document) {
- var command = node.getAttribute("data-command");
- if (command) {
- MM.Command[command].execute();
- return;
- }
- node = node.parentNode;
+ if (e.target == this._toggle) {
+ this.toggle();
+ return;
+ }
+
+ var node = e.target;
+ while (node != document) {
+ var command = node.getAttribute("data-command");
+ if (command) {
+ MM.Command[command].execute();
+ return;
+ }
+ node = node.parentNode;
+ }
+ break;
+
+ case "change":
+ MM.Clipboard.focus(); /* focus the clipboard (2c) */
+ break;
}
}
@@ -3876,7 +4089,7 @@ MM.UI.IO.prototype.show = function(mode) {
MM.UI.IO.prototype.hide = function() {
if (!this._node.classList.contains("visible")) { return; }
this._node.classList.remove("visible");
- document.activeElement && document.activeElement.blur();
+ MM.Clipboard.focus();
window.removeEventListener("keydown", this);
}
@@ -3990,7 +4203,8 @@ MM.UI.Backend.show = function(mode) {
var visible = this._node.querySelectorAll("[data-for~=" + mode + "]");
[].concat.apply([], visible).forEach(function(node) { node.style.display = ""; });
-
+
+ /* switch to 2a: steal focus from the current item */
this._go.focus();
}
@@ -4056,6 +4270,7 @@ MM.UI.Backend.File.init = function(select) {
this._format.appendChild(MM.Format.FreeMind.buildOption());
this._format.appendChild(MM.Format.MMA.buildOption());
this._format.appendChild(MM.Format.Mup.buildOption());
+ this._format.appendChild(MM.Format.Plaintext.buildOption());
this._format.value = localStorage.getItem(this._prefix + "format") || MM.Format.JSON.id;
}
@@ -4469,6 +4684,7 @@ MM.UI.Backend.GDrive.init = function(select) {
this._format.appendChild(MM.Format.FreeMind.buildOption());
this._format.appendChild(MM.Format.MMA.buildOption());
this._format.appendChild(MM.Format.Mup.buildOption());
+ this._format.appendChild(MM.Format.Plaintext.buildOption());
this._format.value = localStorage.getItem(this._prefix + "format") || MM.Format.JSON.id;
}
@@ -4573,11 +4789,11 @@ MM.Mouse.handleEvent = function(e) {
case "contextmenu":
this._endDrag();
+ e.preventDefault();
var item = MM.App.map.getItemFor(e.target);
- if (item) { MM.App.select(item); }
+ item && MM.App.select(item);
- e.preventDefault();
MM.Menu.open(e.clientX, e.clientY);
break;
@@ -4586,6 +4802,7 @@ MM.Mouse.handleEvent = function(e) {
e.clientX = e.touches[0].clientX;
e.clientY = e.touches[0].clientY;
case "mousedown":
+ if (e.type == "mousedown") { e.preventDefault(); } /* to prevent blurring the clipboard node */
var item = MM.App.map.getItemFor(e.target);
if (e.type == "touchstart") { /* context menu here, after we have the item */
@@ -4595,8 +4812,11 @@ MM.Mouse.handleEvent = function(e) {
}, this.TOUCH_DELAY);
}
- if (item == MM.App.current && MM.App.editing) { return; }
- document.activeElement && document.activeElement.blur();
+ if (MM.App.editing) {
+ if (item == MM.App.current) { return; } /* ignore dnd on edited node */
+ MM.Command.Finish.execute(); /* clicked elsewhere => finalize edit */
+ }
+
this._startDrag(e, item);
break;
@@ -4805,6 +5025,40 @@ MM.Mouse._visualizeDragState = function(state) {
node.style.boxShadow = (x*offset) + "px " + (y*offset) + "px 2px " + spread + "px #000";
}
}
+/*
+setInterval(function() {
+ console.log(document.activeElement);
+}, 1000);
+*/
+
+/*
+ * Notes regarding app state/modes, activeElements, focusing etc.
+ * ==============================================================
+ *
+ * 1) There is always exactly one item selected. All executed commands
+ * operate on this item.
+ *
+ * 2) The app distinguishes three modes with respect to focus:
+ * 2a) One of the UI panes has focus (inputs, buttons, selects).
+ * Keyboard shortcuts are disabled.
+ * 2b) Current item is being edited. It is contentEditable and focused.
+ * Blurring ends the edit mode.
+ * 2c) ELSE the Clipboard is focused (its invisible textarea)
+ *
+ * In 2a, we try to lose focus as soon as possible
+ * (after clicking, after changing select's value), switching to 2c.
+ *
+ * 3) Editing mode (2b) can be ended by multiple ways:
+ * 3a) By calling current.stopEditing();
+ * this shall be followed by some resolution.
+ * 3b) By executing MM.Command.{Finish,Cancel};
+ * these call 3a internally.
+ * 3c) By blurring the item itself (by selecting another);
+ * this calls MM.Command.Finish (3b).
+ * 3b) By blurring the currentElement;
+ * this calls MM.Command.Finish (3b).
+ *
+ */
MM.App = {
keyboard: null,
current: null,
@@ -4848,19 +5102,11 @@ MM.App = {
},
select: function(item) {
- if (item == this.current) { return; }
-
- if (this.editing) { MM.Command.Finish.execute(); }
-
- if (this.current) {
- this.current.getDOM().node.classList.remove("current");
- }
+ if (this.current && this.current != item) { this.current.deselect(); }
this.current = item;
- this.current.getDOM().node.classList.add("current");
- this.map.ensureItemVisibility(item);
- MM.publish("item-select", item);
+ this.current.select();
},
-
+
adjustFontSize: function(diff) {
this._fontSize = Math.max(30, this._fontSize + 10*diff);
this._port.style.fontSize = this._fontSize + "%";
@@ -4910,6 +5156,7 @@ MM.App = {
MM.Keyboard.init();
MM.Menu.init(this._port);
MM.Mouse.init(this._port);
+ MM.Clipboard.init();
window.addEventListener("resize", this);
window.addEventListener("beforeunload", this);
diff --git a/src/action.js b/src/action.js
index bb75433..9880886 100644
--- a/src/action.js
+++ b/src/action.js
@@ -2,6 +2,21 @@ MM.Action = function() {}
MM.Action.prototype.perform = function() {}
MM.Action.prototype.undo = function() {}
+MM.Action.Multi = function(actions) {
+ this._actions = actions;
+}
+MM.Action.Multi.prototype = Object.create(MM.Action.prototype);
+MM.Action.Multi.prototype.perform = function() {
+ this._actions.forEach(function(action) {
+ action.perform();
+ });
+}
+MM.Action.Multi.prototype.undo = function() {
+ this._actions.slice().reverse().forEach(function(action) {
+ action.undo();
+ });
+}
+
MM.Action.InsertNewItem = function(parent, index) {
this._parent = parent;
this._index = index;
diff --git a/src/app.js b/src/app.js
index 5153e9e..328bfcb 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,37 @@
+/*
+setInterval(function() {
+ console.log(document.activeElement);
+}, 1000);
+*/
+
+/*
+ * Notes regarding app state/modes, activeElements, focusing etc.
+ * ==============================================================
+ *
+ * 1) There is always exactly one item selected. All executed commands
+ * operate on this item.
+ *
+ * 2) The app distinguishes three modes with respect to focus:
+ * 2a) One of the UI panes has focus (inputs, buttons, selects).
+ * Keyboard shortcuts are disabled.
+ * 2b) Current item is being edited. It is contentEditable and focused.
+ * Blurring ends the edit mode.
+ * 2c) ELSE the Clipboard is focused (its invisible textarea)
+ *
+ * In 2a, we try to lose focus as soon as possible
+ * (after clicking, after changing select's value), switching to 2c.
+ *
+ * 3) Editing mode (2b) can be ended by multiple ways:
+ * 3a) By calling current.stopEditing();
+ * this shall be followed by some resolution.
+ * 3b) By executing MM.Command.{Finish,Cancel};
+ * these call 3a internally.
+ * 3c) By blurring the item itself (by selecting another);
+ * this calls MM.Command.Finish (3b).
+ * 3b) By blurring the currentElement;
+ * this calls MM.Command.Finish (3b).
+ *
+ */
MM.App = {
keyboard: null,
current: null,
@@ -41,19 +75,11 @@ MM.App = {
},
select: function(item) {
- if (item == this.current) { return; }
-
- if (this.editing) { MM.Command.Finish.execute(); }
-
- if (this.current) {
- this.current.getDOM().node.classList.remove("current");
- }
+ if (this.current && this.current != item) { this.current.deselect(); }
this.current = item;
- this.current.getDOM().node.classList.add("current");
- this.map.ensureItemVisibility(item);
- MM.publish("item-select", item);
+ this.current.select();
},
-
+
adjustFontSize: function(diff) {
this._fontSize = Math.max(30, this._fontSize + 10*diff);
this._port.style.fontSize = this._fontSize + "%";
@@ -103,6 +129,7 @@ MM.App = {
MM.Keyboard.init();
MM.Menu.init(this._port);
MM.Mouse.init(this._port);
+ MM.Clipboard.init();
window.addEventListener("resize", this);
window.addEventListener("beforeunload", this);
diff --git a/src/clipboard.js b/src/clipboard.js
index 99cf625..a871e22 100644
--- a/src/clipboard.js
+++ b/src/clipboard.js
@@ -1,61 +1,126 @@
MM.Clipboard = {
- _data: null,
- _mode: ""
+ _item: null,
+ _mode: "",
+ _delay: 50,
+ _node: document.createElement("textarea")
};
+MM.Clipboard.init = function() {
+ this._node.style.position = "absolute";
+ this._node.style.width = 0;
+ this._node.style.height = 0;
+ this._node.style.left = "-100px";
+ this._node.style.top = "-100px";
+ document.body.appendChild(this._node);
+}
+
+MM.Clipboard.focus = function() {
+ this._node.focus();
+ this._empty();
+}
+
MM.Clipboard.copy = function(sourceItem) {
this._endCut();
-
- this._data = sourceItem.clone();
+ this._item = sourceItem.clone();
this._mode = "copy";
+
+ this._expose();
}
MM.Clipboard.paste = function(targetItem) {
- if (!this._data) { return; }
+ setTimeout(function() {
+ var pasted = this._node.value;
+ this._empty();
+ if (!pasted) { return; } /* nothing */
+
+ if (this._item && pasted == MM.Format.Plaintext.to(this._item.toJSON())) { /* pasted a previously copied/cut item */
+ this._pasteItem(this._item, targetItem);
+ } else { /* pasted some external data */
+ this._pastePlaintext(pasted, targetItem);
+ }
+
+ }.bind(this), this._delay);
+}
+MM.Clipboard._pasteItem = function(sourceItem, targetItem) {
switch (this._mode) {
case "cut":
- if (this._data == targetItem || this._data.getParent() == targetItem) { /* abort by pasting on the same node or the parent */
+ if (sourceItem == targetItem || sourceItem.getParent() == targetItem) { /* abort by pasting on the same node or the parent */
this._endCut();
return;
}
var item = targetItem;
while (!item.isRoot()) {
- if (item == this._data) { return; } /* moving to a child => forbidden */
+ if (item == sourceItem) { return; } /* moving to a child => forbidden */
item = item.getParent();
}
- var action = new MM.Action.MoveItem(this._data, targetItem);
+ var action = new MM.Action.MoveItem(sourceItem, targetItem);
MM.App.action(action);
this._endCut();
break;
case "copy":
- var action = new MM.Action.AppendItem(targetItem, this._data.clone());
+ var action = new MM.Action.AppendItem(targetItem, sourceItem.clone());
MM.App.action(action);
break;
}
+}
+
+MM.Clipboard._pastePlaintext = function(plaintext, targetItem) {
+ if (this._mode == "cut") { this._endCut(); } /* external paste => abort cutting */
+
+ var json = MM.Format.Plaintext.from(plaintext);
+ var map = MM.Map.fromJSON(json);
+ var root = map.getRoot();
+ if (root.getText()) {
+ var action = new MM.Action.AppendItem(targetItem, root);
+ MM.App.action(action);
+ } else {
+ var actions = root.getChildren().map(function(item) {
+ return new MM.Action.AppendItem(targetItem, item);
+ });
+ var action = new MM.Action.Multi(actions);
+ MM.App.action(action);
+ }
}
MM.Clipboard.cut = function(sourceItem) {
this._endCut();
- this._data = sourceItem;
+ this._item = sourceItem;
+ this._item.getDOM().node.classList.add("cut");
this._mode = "cut";
- var node = this._data.getDOM().node;
- node.classList.add("cut");
+ this._expose();
+}
+
+/**
+ * Expose plaintext data to the textarea to be copied to system clipboard. Clear afterwards.
+ */
+MM.Clipboard._expose = function() {
+ var json = this._item.toJSON();
+ var plaintext = MM.Format.Plaintext.to(json);
+ this._node.value = plaintext;
+ this._node.selectionStart = 0;
+ this._node.selectionEnd = this._node.value.length;
+ setTimeout(this._empty.bind(this), this._delay);
+}
+
+MM.Clipboard._empty = function() {
+ /* safari needs a non-empty selection in order to actually perfrom a real copy on cmd+c */
+ this._node.value = "\n";
+ this._node.selectionStart = 0;
+ this._node.selectionEnd = this._node.value.length;
}
MM.Clipboard._endCut = function() {
if (this._mode != "cut") { return; }
- var node = this._data.getDOM().node;
- node.classList.remove("cut");
-
- this._data = null;
+ this._item.getDOM().node.classList.remove("cut");
+ this._item = null;
this._mode = "";
}
diff --git a/src/command.edit.js b/src/command.edit.js
index 2976623..94af92b 100644
--- a/src/command.edit.js
+++ b/src/command.edit.js
@@ -101,7 +101,7 @@ MM.Command.Strikethrough = Object.create(MM.Command.Style, {
MM.Command.Value = Object.create(MM.Command, {
label: {value: "Set value"},
- keys: {value: [{charCode: "v".charCodeAt(0), ctrlKey:false}]}
+ keys: {value: [{charCode: "v".charCodeAt(0), ctrlKey:false, metaKey:false}]}
});
MM.Command.Value.execute = function() {
var item = MM.App.current;
@@ -140,7 +140,7 @@ MM.Command.No.execute = function() {
MM.Command.Computed = Object.create(MM.Command, {
label: {value: "Computed"},
- keys: {value: [{charCode: "c".charCodeAt(0), ctrlKey:false}]}
+ keys: {value: [{charCode: "c".charCodeAt(0), ctrlKey:false, metaKey:false}]}
});
MM.Command.Computed.execute = function() {
var item = MM.App.current;
diff --git a/src/command.js b/src/command.js
index 663a146..792415d 100644
--- a/src/command.js
+++ b/src/command.js
@@ -1,6 +1,7 @@
MM.Command = Object.create(MM.Repo, {
keys: {value: []},
editMode: {value: false},
+ prevent: {value: true}, /* prevent default keyboard action? */
label: {value: ""}
});
@@ -244,7 +245,11 @@ MM.Command.Pan.handleEvent = function(e) {
MM.Command.Copy = Object.create(MM.Command, {
label: {value: "Copy"},
- keys: {value: [{keyCode: "C".charCodeAt(0), ctrlKey:true}]}
+ prevent: {value: false},
+ keys: {value: [
+ {keyCode: "C".charCodeAt(0), ctrlKey:true},
+ {keyCode: "C".charCodeAt(0), metaKey:true}
+ ]}
});
MM.Command.Copy.execute = function() {
MM.Clipboard.copy(MM.App.current);
@@ -252,7 +257,11 @@ MM.Command.Copy.execute = function() {
MM.Command.Cut = Object.create(MM.Command, {
label: {value: "Cut"},
- keys: {value: [{keyCode: "X".charCodeAt(0), ctrlKey:true}]}
+ prevent: {value: false},
+ keys: {value: [
+ {keyCode: "X".charCodeAt(0), ctrlKey:true},
+ {keyCode: "X".charCodeAt(0), metaKey:true}
+ ]}
});
MM.Command.Cut.execute = function() {
MM.Clipboard.cut(MM.App.current);
@@ -260,7 +269,11 @@ MM.Command.Cut.execute = function() {
MM.Command.Paste = Object.create(MM.Command, {
label: {value: "Paste"},
- keys: {value: [{keyCode: "V".charCodeAt(0), ctrlKey:true}]}
+ prevent: {value: false},
+ keys: {value: [
+ {keyCode: "V".charCodeAt(0), ctrlKey:true},
+ {keyCode: "V".charCodeAt(0), metaKey:true}
+ ]}
});
MM.Command.Paste.execute = function() {
MM.Clipboard.paste(MM.App.current);
diff --git a/src/format.freemind.js b/src/format.freemind.js
index 52c037f..8c7ad1c 100644
--- a/src/format.freemind.js
+++ b/src/format.freemind.js
@@ -46,7 +46,7 @@ MM.Format.FreeMind._serializeItem = function(doc, json) {
MM.Format.FreeMind._serializeAttributes = function(doc, json) {
var elm = doc.createElement("node");
- elm.setAttribute("TEXT", json.text);
+ elm.setAttribute("TEXT", MM.Format.br2nl(json.text));
elm.setAttribute("ID", json.id);
if (json.side) { elm.setAttribute("POSITION", json.side); }
@@ -72,7 +72,7 @@ MM.Format.FreeMind._parseNode = function(node, parent) {
MM.Format.FreeMind._parseAttributes = function(node, parent) {
var json = {
children: [],
- text: node.getAttribute("TEXT") || "",
+ text: MM.Format.nl2br(node.getAttribute("TEXT") || ""),
id: node.getAttribute("ID")
};
diff --git a/src/format.js b/src/format.js
index cd1a764..3f40063 100644
--- a/src/format.js
+++ b/src/format.js
@@ -16,3 +16,11 @@ MM.Format.getByMime = function(mime) {
MM.Format.to = function(data) {}
MM.Format.from = function(data) {}
+
+MM.Format.nl2br = function(str) {
+ return str.replace(/\n/g, "
");
+}
+
+MM.Format.br2nl = function(str) {
+ return str.replace(/
/g, "\n");
+}
diff --git a/src/format.json.js b/src/format.json.js
index 3bab0f5..3b1b9a3 100644
--- a/src/format.json.js
+++ b/src/format.json.js
@@ -6,7 +6,7 @@ MM.Format.JSON = Object.create(MM.Format, {
});
MM.Format.JSON.to = function(data) {
- return JSON.stringify(data, null, 2) + "\n";
+ return JSON.stringify(data, null, "\t") + "\n";
}
MM.Format.JSON.from = function(data) {
diff --git a/src/format.mma.js b/src/format.mma.js
index 1bba5f3..08e3d9d 100644
--- a/src/format.mma.js
+++ b/src/format.mma.js
@@ -7,7 +7,7 @@ MM.Format.MMA = Object.create(MM.Format.FreeMind, {
MM.Format.MMA._parseAttributes = function(node, parent) {
var json = {
children: [],
- text: node.getAttribute("title") || "",
+ text: MM.Format.nl2br(node.getAttribute("title") || ""),
shape: "box"
};
@@ -37,7 +37,7 @@ MM.Format.MMA._parseAttributes = function(node, parent) {
MM.Format.MMA._serializeAttributes = function(doc, json) {
var elm = doc.createElement("node");
- elm.setAttribute("title", json.text);
+ elm.setAttribute("title", MM.Format.br2nl(json.text));
elm.setAttribute("expand", json.collapsed ? "false" : "true");
if (json.side) { elm.setAttribute("direction", json.side == "left" ? "0" : "1"); }
diff --git a/src/format.mup.js b/src/format.mup.js
index e32fb52..68d04e8 100644
--- a/src/format.mup.js
+++ b/src/format.mup.js
@@ -23,7 +23,7 @@ MM.Format.Mup.from = function(data) {
MM.Format.Mup._MupToMM = function(item) {
var json = {
- text: item.title,
+ text: MM.Format.nl2br(item.title),
id: item.id,
shape: "box"
}
@@ -61,7 +61,7 @@ MM.Format.Mup._MupToMM = function(item) {
MM.Format.Mup._MMtoMup = function(item, side) {
var result = {
id: item.id,
- title: item.text,
+ title: MM.Format.br2nl(item.text),
attr: {}
}
if (item.color) {
diff --git a/src/format.plaintext.js b/src/format.plaintext.js
new file mode 100644
index 0000000..7499486
--- /dev/null
+++ b/src/format.plaintext.js
@@ -0,0 +1,86 @@
+MM.Format.Plaintext = Object.create(MM.Format, {
+ id: {value: "plaintext"},
+ label: {value: "Plain text"},
+ extension: {value: "txt"},
+ mime: {value: "application/vnd.mymind+txt"}
+});
+
+/**
+ * Can serialize also a sub-tree
+ */
+MM.Format.Plaintext.to = function(data) {
+ return this._serializeItem(data.root || data);
+}
+
+MM.Format.Plaintext.from = function(data) {
+ var lines = data.split("\n").filter(function(line) {
+ return line.match(/\S/);
+ });
+
+ var items = this._parseItems(lines);
+
+ if (items.length == 1) {
+ var result = {
+ root: items[0]
+ }
+ } else {
+ var result = {
+ root: {
+ text: "",
+ children: items
+ }
+ }
+ }
+ result.root.layout = "map";
+
+ return result;
+}
+
+MM.Format.Plaintext._serializeItem = function(item, depth) {
+ depth = depth || 0;
+
+ var lines = (item.children || []) .map(function(child) {
+ return this._serializeItem(child, depth+1);
+ }, this);
+
+ var prefix = new Array(depth+1).join("\t");
+ lines.unshift(prefix + item.text.replace(/\n/g, ""));
+
+ return lines.join("\n") + (depth ? "" : "\n");
+}
+
+
+MM.Format.Plaintext._parseItems = function(lines) {
+ var items = [];
+ if (!lines.length) { return items; }
+ var firstPrefix = this._parsePrefix(lines[0]);
+
+ var currentItem = null;
+ var childLines = [];
+
+ /* finalize a block of sub-children by converting them to items and appending */
+ var convertChildLinesToChildren = function() {
+ if (!currentItem || !childLines.length) { return; }
+ var children = this._parseItems(childLines);
+ if (children.length) { currentItem.children = children; }
+ childLines = [];
+ }
+
+ lines.forEach(function(line, index) {
+ if (this._parsePrefix(line) == firstPrefix) { /* new top-level item! */
+ convertChildLinesToChildren.call(this); /* finalize previous item */
+ currentItem = {text:line.match(/^\s*(.*)/)[1]};
+ items.push(currentItem);
+ } else { /* prepare as a future child */
+ childLines.push(line);
+ }
+ }, this);
+
+ convertChildLinesToChildren.call(this);
+
+ return items;
+}
+
+MM.Format.Plaintext._parsePrefix = function(line) {
+ return line.match(/^\s*/)[0];
+}
diff --git a/src/item.js b/src/item.js
index c76e8a5..e6ab595 100644
--- a/src/item.js
+++ b/src/item.js
@@ -173,6 +173,19 @@ MM.Item.prototype.clone = function() {
return this.constructor.fromJSON(data);
}
+MM.Item.prototype.select = function() {
+ this._dom.node.classList.add("current");
+ this.getMap().ensureItemVisibility(this);
+ MM.Clipboard.focus(); /* going to mode 2c */
+ MM.publish("item-select", this);
+}
+
+MM.Item.prototype.deselect = function() {
+ /* we were in 2b; finish that via 3b */
+ if (MM.App.editing) { MM.Command.Finish.execute(); }
+ this._dom.node.classList.remove("current");
+}
+
MM.Item.prototype.update = function(doNotRecurse) {
var map = this.getMap();
if (!map || !map.isVisible()) { return this; }
@@ -208,7 +221,7 @@ MM.Item.prototype.updateSubtree = function(isSubChild) {
}
MM.Item.prototype.setText = function(text) {
- this._dom.text.innerHTML = text.replace(/\n/g, "
");
+ this._dom.text.innerHTML = text;
this._findLinks(this._dom.text);
return this.update();
}
@@ -218,7 +231,7 @@ MM.Item.prototype.getId = function() {
}
MM.Item.prototype.getText = function() {
- return this._dom.text.innerHTML.replace(/
/g, "\n");
+ return this._dom.text.innerHTML;
}
MM.Item.prototype.collapse = function() {
@@ -358,7 +371,7 @@ MM.Item.prototype.insertChild = function(child, index) {
if (!child) {
child = new MM.Item();
newChild = true;
- } else if (child.getParent()) {
+ } else if (child.getParent() && child.getParent().removeChild) { /* only when the child has non-map parent */
child.getParent().removeChild(child);
}
@@ -396,7 +409,7 @@ MM.Item.prototype.removeChild = function(child) {
MM.Item.prototype.startEditing = function() {
this._oldText = this.getText();
this._dom.text.contentEditable = true;
- this._dom.text.focus();
+ this._dom.text.focus(); /* switch to 2b */
document.execCommand("styleWithCSS", null, false);
this._dom.text.addEventListener("input", this);
@@ -417,6 +430,9 @@ MM.Item.prototype.stopEditing = function() {
this._oldText = "";
this.update(); /* text changed */
+
+ MM.Clipboard.focus();
+
return result;
}
@@ -431,7 +447,7 @@ MM.Item.prototype.handleEvent = function(e) {
if (e.keyCode == 9) { e.preventDefault(); } /* TAB has a special meaning in this app, do not use it to change focus */
break;
- case "blur":
+ case "blur": /* 3d */
MM.Command.Finish.execute();
break;
diff --git a/src/keyboard.js b/src/keyboard.js
index d668c1b..475c15a 100644
--- a/src/keyboard.js
+++ b/src/keyboard.js
@@ -5,6 +5,7 @@ MM.Keyboard.init = function() {
}
MM.Keyboard.handleEvent = function(e) {
+ /* mode 2a: ignore keyboard when the activeElement resides somewhere inside of the UI pane */
var node = document.activeElement;
while (node && node != document) {
if (node.classList.contains("ui")) { return; }
@@ -18,7 +19,7 @@ MM.Keyboard.handleEvent = function(e) {
var keys = command.keys;
for (var j=0;j/g, "").trim();
+ return MM.Format.br2nl(name).replace(/\n/g, " ").replace(/<.*?>/g, "").trim();
}
MM.Map.prototype.getId = function() {
diff --git a/src/mouse.js b/src/mouse.js
index d580a17..281c726 100644
--- a/src/mouse.js
+++ b/src/mouse.js
@@ -35,11 +35,11 @@ MM.Mouse.handleEvent = function(e) {
case "contextmenu":
this._endDrag();
+ e.preventDefault();
var item = MM.App.map.getItemFor(e.target);
- if (item) { MM.App.select(item); }
+ item && MM.App.select(item);
- e.preventDefault();
MM.Menu.open(e.clientX, e.clientY);
break;
@@ -48,6 +48,7 @@ MM.Mouse.handleEvent = function(e) {
e.clientX = e.touches[0].clientX;
e.clientY = e.touches[0].clientY;
case "mousedown":
+ if (e.type == "mousedown") { e.preventDefault(); } /* to prevent blurring the clipboard node */
var item = MM.App.map.getItemFor(e.target);
if (e.type == "touchstart") { /* context menu here, after we have the item */
@@ -57,8 +58,11 @@ MM.Mouse.handleEvent = function(e) {
}, this.TOUCH_DELAY);
}
- if (item == MM.App.current && MM.App.editing) { return; }
- document.activeElement && document.activeElement.blur();
+ if (MM.App.editing) {
+ if (item == MM.App.current) { return; } /* ignore dnd on edited node */
+ MM.Command.Finish.execute(); /* clicked elsewhere => finalize edit */
+ }
+
this._startDrag(e, item);
break;
diff --git a/src/ui.backend.file.js b/src/ui.backend.file.js
index 34806c4..cb066f5 100644
--- a/src/ui.backend.file.js
+++ b/src/ui.backend.file.js
@@ -10,6 +10,7 @@ MM.UI.Backend.File.init = function(select) {
this._format.appendChild(MM.Format.FreeMind.buildOption());
this._format.appendChild(MM.Format.MMA.buildOption());
this._format.appendChild(MM.Format.Mup.buildOption());
+ this._format.appendChild(MM.Format.Plaintext.buildOption());
this._format.value = localStorage.getItem(this._prefix + "format") || MM.Format.JSON.id;
}
diff --git a/src/ui.backend.gdrive.js b/src/ui.backend.gdrive.js
index dec7941..533fd9d 100644
--- a/src/ui.backend.gdrive.js
+++ b/src/ui.backend.gdrive.js
@@ -10,6 +10,7 @@ MM.UI.Backend.GDrive.init = function(select) {
this._format.appendChild(MM.Format.FreeMind.buildOption());
this._format.appendChild(MM.Format.MMA.buildOption());
this._format.appendChild(MM.Format.Mup.buildOption());
+ this._format.appendChild(MM.Format.Plaintext.buildOption());
this._format.value = localStorage.getItem(this._prefix + "format") || MM.Format.JSON.id;
}
diff --git a/src/ui.backend.js b/src/ui.backend.js
index feb709e..e1e5465 100644
--- a/src/ui.backend.js
+++ b/src/ui.backend.js
@@ -55,7 +55,8 @@ MM.UI.Backend.show = function(mode) {
var visible = this._node.querySelectorAll("[data-for~=" + mode + "]");
[].concat.apply([], visible).forEach(function(node) { node.style.display = ""; });
-
+
+ /* switch to 2a: steal focus from the current item */
this._go.focus();
}
diff --git a/src/ui.io.js b/src/ui.io.js
index 185440a..ed3fc78 100644
--- a/src/ui.io.js
+++ b/src/ui.io.js
@@ -84,7 +84,7 @@ MM.UI.IO.prototype.show = function(mode) {
MM.UI.IO.prototype.hide = function() {
if (!this._node.classList.contains("visible")) { return; }
this._node.classList.remove("visible");
- document.activeElement && document.activeElement.blur();
+ MM.Clipboard.focus();
window.removeEventListener("keydown", this);
}
diff --git a/src/ui.js b/src/ui.js
index b7ca2a0..c649d7c 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -1,6 +1,5 @@
MM.UI = function() {
this._node = document.querySelector(".ui");
- this._node.addEventListener("click", this);
this._toggle = this._node.querySelector("#toggle");
@@ -10,8 +9,11 @@ MM.UI = function() {
this._value = new MM.UI.Value();
this._status = new MM.UI.Status();
- MM.subscribe("item-change", this);
MM.subscribe("item-select", this);
+ MM.subscribe("item-change", this);
+
+ this._node.addEventListener("click", this);
+ this._node.addEventListener("change", this);
this.toggle();
}
@@ -29,22 +31,29 @@ MM.UI.prototype.handleMessage = function(message, publisher) {
}
MM.UI.prototype.handleEvent = function(e) {
- /* blur to return focus back to app commands */
- if (e.target.nodeName.toLowerCase() != "select") { e.target.blur(); }
+ switch (e.type) {
+ case "click":
+ if (e.target.nodeName.toLowerCase() != "select") { MM.Clipboard.focus(); } /* focus the clipboard (2c) */
- if (e.target == this._toggle) {
- this.toggle();
- return;
- }
-
- var node = e.target;
- while (node != document) {
- var command = node.getAttribute("data-command");
- if (command) {
- MM.Command[command].execute();
- return;
- }
- node = node.parentNode;
+ if (e.target == this._toggle) {
+ this.toggle();
+ return;
+ }
+
+ var node = e.target;
+ while (node != document) {
+ var command = node.getAttribute("data-command");
+ if (command) {
+ MM.Command[command].execute();
+ return;
+ }
+ node = node.parentNode;
+ }
+ break;
+
+ case "change":
+ MM.Clipboard.focus(); /* focus the clipboard (2c) */
+ break;
}
}