File size: 7,893 Bytes
aceb1b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
/*
 * Universal Memos sidebar.
 *
 * Memos persist immediately via /api/memos and Potato navigation is a
 * full page reload, so correctness just requires (re)loading memos for
 * the displayed instance on init. A window.MemoPanel.reload() hook is
 * exposed so that if navigation ever becomes AJAX, the panel can be
 * refreshed from the instance lifecycle without stale state.
 */
(function () {
    "use strict";

    var API = "/api/memos";
    var state = { instanceId: null, memos: [], editingId: null, enabled: false };

    function el(id) { return document.getElementById(id); }

    function currentInstanceId() {
        var input = el("instance_id");
        return input ? input.value : null;
    }

    function esc(s) {
        var d = document.createElement("div");
        d.textContent = s == null ? "" : String(s);
        return d.innerHTML;
    }

    function selectionInText() {
        // Returns {start,end,field,quote} if there is a non-empty selection
        // inside the instance text, else null.
        var sel = window.getSelection();
        if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
        var container = document.getElementById("text-content");
        if (!container) return null;
        var range = sel.getRangeAt(0);
        if (!container.contains(range.startContainer) ||
            !container.contains(range.endContainer)) return null;
        var pre = range.cloneRange();
        pre.selectNodeContents(container);
        pre.setEnd(range.startContainer, range.startOffset);
        var start = pre.toString().length;
        var quote = sel.toString();
        if (!quote.trim()) return null;
        return { start: start, end: start + quote.length, field: "text", quote: quote };
    }

    function api(method, path, body) {
        return fetch(API + path, {
            method: method,
            headers: { "Content-Type": "application/json" },
            body: body ? JSON.stringify(body) : undefined,
        });
    }

    function render() {
        var list = el("memo-list");
        if (!list) return;
        if (!state.memos.length) {
            list.innerHTML = '<div class="memo-empty">No notes on this instance yet.</div>';
            return;
        }
        var me = (window.config && window.config.username) || null;
        list.innerHTML = state.memos.map(function (m) {
            var own = m.created_by === me;
            var vis = m.visibility === "shared" ? "shared" : "private";
            var anchorBadge = m.anchor
                ? '<span class="memo-badge anchor" title="Attached to a text selection">quote</span>'
                : "";
            var actions = own
                ? '<span class="memo-actions">'
                  + '<button data-act="edit" data-id="' + m.id + '">Edit</button>'
                  + '<button data-act="del" data-id="' + m.id + '">Delete</button>'
                  + "</span>"
                : "";
            return '<div class="memo-item">'
                + '<div class="memo-body">' + esc(m.body) + "</div>"
                + '<div class="memo-meta">'
                + "<span>" + esc(m.created_by) + "</span>"
                + '<span class="memo-badge ' + vis + '">' + vis + "</span>"
                + anchorBadge + actions
                + "</div></div>";
        }).join("");
        list.querySelectorAll("button[data-act]").forEach(function (b) {
            b.addEventListener("click", function () {
                var id = b.getAttribute("data-id");
                if (b.getAttribute("data-act") === "del") return doDelete(id);
                return startEdit(id);
            });
        });
    }

    function load() {
        state.instanceId = currentInstanceId();
        if (!state.instanceId) return Promise.resolve();
        return api("GET", "?instance_id=" + encodeURIComponent(state.instanceId))
            .then(function (res) {
                if (res.status === 503) { state.enabled = false; return null; }
                state.enabled = true;
                var t = el("memo-panel-toggle");
                if (t) t.hidden = false;
                return res.json();
            })
            .then(function (data) {
                if (!data) return;
                state.memos = data.memos || [];
                render();
            })
            .catch(function () { /* network: leave panel as-is */ });
    }

    function resetComposer() {
        state.editingId = null;
        var body = el("memo-new-body");
        if (body) body.value = "";
        var anchorWrap = el("memo-anchor-wrap");
        if (anchorWrap) anchorWrap.hidden = true;
        var btn = el("memo-add-btn");
        if (btn) btn.textContent = "Add note";
    }

    function startEdit(id) {
        var m = state.memos.filter(function (x) { return x.id === id; })[0];
        if (!m) return;
        state.editingId = id;
        el("memo-new-body").value = m.body;
        el("memo-new-visibility").value = m.visibility;
        el("memo-add-btn").textContent = "Save";
        el("memo-new-body").focus();
    }

    function doDelete(id) {
        api("DELETE", "/" + encodeURIComponent(id)).then(function () {
            load();
        });
    }

    function submit() {
        var bodyEl = el("memo-new-body");
        var body = (bodyEl && bodyEl.value || "").trim();
        if (!body) return;
        var visibility = el("memo-new-visibility").value;
        if (state.editingId) {
            api("PATCH", "/" + encodeURIComponent(state.editingId),
                { body: body, visibility: visibility })
                .then(function () { resetComposer(); load(); });
            return;
        }
        var payload = {
            instance_id: state.instanceId,
            body: body,
            visibility: visibility,
        };
        var anchorCheck = el("memo-anchor-check");
        if (anchorCheck && anchorCheck.checked && state._pendingAnchor) {
            payload.anchor = {
                start: state._pendingAnchor.start,
                end: state._pendingAnchor.end,
                field: state._pendingAnchor.field,
            };
        }
        api("POST", "", payload).then(function (res) {
            if (res.ok) { resetComposer(); load(); }
        });
    }

    function wireSelectionAffordance() {
        document.addEventListener("selectionchange", function () {
            var wrap = el("memo-anchor-wrap");
            if (!wrap) return;
            var s = selectionInText();
            state._pendingAnchor = s;
            if (s) {
                wrap.hidden = false;
                el("memo-anchor-quote").textContent =
                    s.quote.length > 60 ? s.quote.slice(0, 60) + "…" : s.quote;
            } else {
                wrap.hidden = true;
            }
        });
    }

    function wire() {
        var toggle = el("memo-panel-toggle");
        var panel = el("memo-panel");
        var close = el("memo-panel-close");
        if (toggle && panel) {
            toggle.addEventListener("click", function () {
                panel.hidden = false;
                toggle.hidden = true;
                render();
            });
        }
        if (close && panel && toggle) {
            close.addEventListener("click", function () {
                panel.hidden = true;
                toggle.hidden = false;
            });
        }
        var add = el("memo-add-btn");
        if (add) add.addEventListener("click", submit);
        wireSelectionAffordance();
    }

    function init() {
        if (!window.config || !window.config.is_annotation_page) return;
        if (!el("memo-panel")) return;
        wire();
        load();
    }

    window.MemoPanel = { reload: load };

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }
})();