solid-ui
Version:
UI library for Solid applications
808 lines • 31.6 kB
JavaScript
/** **************
* Notepad Widget
*/
/** @module pad
*/
import ns from './ns';
import { Namespace, NamedNode, st } from 'rdflib';
import { newThing, errorMessageBlock } from './widgets';
import { beep } from './utils';
import { log } from './debug';
import { solidLogicSingleton } from 'solid-logic';
import { style } from './style';
export { renderParticipants, participationObject, manageParticipation, recordParticipation } from './participation';
const store = solidLogicSingleton.store;
const PAD = Namespace('http://www.w3.org/ns/pim/pad#');
/**
* @ignore
*/
class NotepadElement extends HTMLElement {
}
/**
* @ignore
*/
class NotepadPart extends HTMLElement {
}
/** Figure out a random color from my webid
*
* @param {NamedNode} author - The author of text being displayed
* @returns {String} The CSS color generated, constrained to be light for a background color
*/
export function lightColorHash(author) {
const hash = function (x) {
return x.split('').reduce(function (a, b) {
a = (a << 5) - a + b.charCodeAt(0);
return a & a;
}, 0);
};
return author && author.uri
? '#' + ((hash(author.uri) & 0xffffff) | 0xc0c0c0).toString(16)
: '#ffffff'; // c0c0c0 forces pale
} // no id -> white
/** notepad
*
* @param {HTMLDocument} dom - the web page of the browser
* @param {NamedNode} padDoc - the document in which the participation should be shown
* @param {NamedNode} subject - the thing in which participation is happening
* @param {NamedNode} me - person who is logged into the pod
* @param {notepadOptions} options - the options that can be passed in consist of statusArea, exists
*/
export function notepad(dom, padDoc, subject, me, options) {
options = options || {};
const exists = options.exists;
const table = dom.createElement('table');
const kb = store;
if (me && !me.uri)
throw new Error('UI.pad.notepad: Invalid userid');
const updater = store.updater;
const PAD = Namespace('http://www.w3.org/ns/pim/pad#');
table.setAttribute('style', style.notepadStyle);
let upstreamStatus = null;
let downstreamStatus = null;
if (options.statusArea) {
const t = options.statusArea.appendChild(dom.createElement('table'));
const tr = t.appendChild(dom.createElement('tr'));
upstreamStatus = tr.appendChild(dom.createElement('td'));
downstreamStatus = tr.appendChild(dom.createElement('td'));
if (upstreamStatus) {
upstreamStatus.setAttribute('style', style.upstreamStatus);
}
if (downstreamStatus) {
downstreamStatus.setAttribute('style', style.downstreamStatus);
}
}
/* @@ TODO want to look into this, it seems upstream should be a boolean and default to false ?
*
*/
const complain = function (message, upstream = false) {
log(message);
if (options.statusArea) {
;
(upstream
? upstreamStatus
: downstreamStatus).appendChild(errorMessageBlock(dom, message, 'pink'));
}
};
// @@ TODO need to refactor so that we don't have to type cast
const clearStatus = function (_upsteam) {
if (options.statusArea) {
;
options.statusArea.innerHTML = '';
}
};
const setPartStyle = function (part, colors, pending) {
const chunk = part.subject;
colors = colors || '';
const baseStyle = style.baseStyle;
const headingCore = style.headingCore;
const headingStyle = style.headingStyle;
const author = kb.any(chunk, ns.dc('author'));
if (!colors && author) {
// Hash the user webid for now -- later allow user selection!
const bgcolor = lightColorHash(author);
colors =
'color: ' +
(pending ? '#888' : 'black') +
'; background-color: ' +
bgcolor +
';';
}
// @@ TODO Need to research when this can be an object with the indent stored in value
// and when the indent is stored as a Number itself, not in an object.
let indent = kb.any(chunk, PAD('indent'));
indent = indent ? indent.value : 0;
const localStyle = indent >= 0
? baseStyle + 'text-indent: ' + indent * 3 + 'em;'
: headingCore + headingStyle[-1 - indent];
// ? baseStyle + 'padding-left: ' + (indent * 3) + 'em;'
part.setAttribute('style', localStyle + colors);
};
const removePart = function (part) {
const chunk = part.subject;
if (!chunk)
throw new Error('No chunk for line to be deleted!'); // just in case
const prev = kb.any(undefined, PAD('next'), chunk);
const next = kb.any(chunk, PAD('next'));
if (prev.sameTerm(subject) && next.sameTerm(subject)) {
// Last one
log("You can't delete the only line.");
return;
}
const del = kb
.statementsMatching(chunk, undefined, undefined, padDoc)
.concat(kb.statementsMatching(undefined, undefined, chunk, padDoc));
const ins = [st(prev, PAD('next'), next, padDoc)];
// @@ TODO what should we do if chunk is not a NamedNode should we
// assume then it is a string?
if (chunk instanceof NamedNode) {
const label = chunk.uri.slice(-4);
log('Deleting line ' + label);
}
if (!updater) {
throw new Error('have no updater');
}
// @@ TODO below you can see that before is redefined and not a boolean
updater.update(del, ins, function (uri, ok, errorMessage, response) {
if (ok) {
const row = part.parentNode;
if (row) {
const before = row.previousSibling;
if (row.parentNode) {
row.parentNode.removeChild(row);
}
// console.log(' deleted line ' + label + ' ok ' + part.value)
if (before && before.firstChild) {
// @@ TODO IMPORTANT FOCUS ISN'T A PROPERTY ON A CHILDNODE
before.firstChild.focus();
}
}
}
else if (response && response.status === 409) {
// Conflict
setPartStyle(part, 'color: black; background-color: #ffd;'); // yellow
part.state = 0; // Needs downstream refresh
beep(0.5, 512); // Ooops clash with other person
setTimeout(function () {
// Ideally, beep! @@
reloadAndSync(); // Throw away our changes and
// updater.requestDownstreamAction(padDoc, reloadAndSync)
}, 1000);
}
else {
log(' removePart FAILED ' + chunk + ': ' + errorMessage);
log(" removePart was deleting :'" + del);
setPartStyle(part, 'color: black; background-color: #fdd;'); // failed
const res = response
? response.status
: ' [no response field] ';
complain('Error ' + res + ' saving changes: ' + String(errorMessage)); // upstream,
// updater.requestDownstreamAction(padDoc, reloadAndSync);
}
});
}; // removePart
const changeIndent = function (part, chunk, delta) {
const del = kb.statementsMatching(chunk, PAD('indent'));
const current = del.length ? Number(del[0].object.value) : 0;
if (current + delta < -3)
return; // limit negative indent
const newIndent = current + delta;
const ins = st(chunk, PAD('indent'), newIndent, padDoc);
if (!updater) {
throw new Error('no updater');
}
updater.update(del, ins, function (uri, ok, errorBody) {
if (!ok) {
log("Indent change FAILED '" +
newIndent +
"' for " +
padDoc +
': ' +
errorBody);
setPartStyle(part, 'color: black; background-color: #fdd;'); // failed
updater.requestDownstreamAction(padDoc, reloadAndSync);
}
else {
setPartStyle(part); // Implement the indent
}
});
};
const addListeners = function (part, chunk) {
let inputDebounceTimer = null;
part.addEventListener('keydown', function (event) {
if (!updater) {
throw new Error('no updater');
}
let queueProperty, queue;
// up 38; down 40; left 37; right 39 tab 9; shift 16; escape 27
switch (event.keyCode) {
case 13: { // Return
const before = event.shiftKey;
log('enter'); // Shift-return inserts before -- only way to add to top of pad.
if (before) {
queue = kb.any(undefined, PAD('next'), chunk);
queueProperty = 'newlinesAfter';
}
else {
queue = kb.any(chunk, PAD('next'));
queueProperty = 'newlinesBefore';
}
queue[queueProperty] = queue[queueProperty] || 0;
queue[queueProperty] += 1;
if (queue[queueProperty] > 1) {
log(' queueing newline queue = ' + queue[queueProperty]);
return;
}
log(' go ahead line before ' + queue[queueProperty]);
newChunk(part, before); // was document.activeElement
break;
}
case 8: // Delete
if (part.value.length === 0) {
log('Delete key line ' + chunk.uri.slice(-4) + ' state ' + part.state);
switch (part.state) {
case 1: // contents being sent
case 2: // contents need to be sent again
part.state = 4; // delete me
return;
case 3: // already being deleted
case 4: // already deleted state
return;
case undefined:
case 0:
part.state = 3; // being deleted
removePart(part);
event.preventDefault();
break; // continue
default:
throw new Error('pad: Unexpected state ' + part);
}
}
break;
case 9: { // Tab
const delta = event.shiftKey ? -1 : 1;
changeIndent(part, chunk, delta);
event.preventDefault(); // default is to highlight next field
break;
}
case 27: // ESC
log('escape');
updater.requestDownstreamAction(padDoc, reloadAndSync);
event.preventDefault();
break;
case 38: // Up
if (part.parentNode.previousSibling) {
part.parentNode.previousSibling.firstChild.focus();
event.preventDefault();
}
break;
case 40: // Down
if (part.parentNode.nextSibling) {
part.parentNode.nextSibling.firstChild.focus();
event.preventDefault();
}
break;
default:
}
});
const updateStore = function (part) {
const chunk = part.subject;
setPartStyle(part, undefined, true);
const old = kb.any(chunk, ns.sioc('content')).value;
const del = [st(chunk, ns.sioc('content'), old, padDoc)];
let ins;
if (part.value) {
ins = [st(chunk, ns.sioc('content'), part.value, padDoc)];
}
const newOne = part.value;
// DEBUGGING ONLY
if (part.lastSent) {
if (old !== part.lastSent) {
// Non-fatal: log a warning instead of throwing, to avoid crashing the pad UI.
console.warn("Out of order, last sent expected '" +
old +
"' but found '" +
part.lastSent +
"'");
}
}
part.lastSent = newOne;
/* console.log(
' Patch proposed to ' +
chunk.uri.slice(-4) +
" '" +
old +
"' -> '" +
newOne +
"' "
) */
if (!updater) {
throw new Error('no updater');
}
updater.update(del, ins, function (uri, ok, errorBody, xhr) {
if (!ok) {
// alert("clash " + errorBody);
log(' patch FAILED ' +
xhr.status +
" for '" +
old +
"' -> '" +
newOne +
"': " +
errorBody);
if (xhr.status === 409) {
// Conflict - @@ we assume someone else
setPartStyle(part, 'color: black; background-color: #fdd;');
part.state = 0; // Needs downstream refresh
beep(0.5, 512); // Ooops clash with other person
setTimeout(function () {
updater.requestDownstreamAction(padDoc, reloadAndSync);
}, 1000);
}
else {
setPartStyle(part, 'color: black; background-color: #fdd;'); // failed pink
const status = xhr === null || xhr === void 0 ? void 0 : xhr.status;
if (!status || status === 502 || status === 503) {
// Transient server error – retry after a short delay
part.lastSent = undefined;
part.state = 0;
setTimeout(() => {
if (part.state === 0 || part.state === undefined) {
part.state = 1;
updateStore(part);
}
}, 2000);
}
else {
part.state = 0;
complain(' Error ' + status + ' sending data: ' + errorBody, true);
beep(1.0, 128); // Other
// @@@ Do something more serious with other errors eg auth, etc
}
}
}
else {
clearStatus(true); // upstream
setPartStyle(part); // synced
log(" Patch ok '" + old + "' -> '" + newOne + "' ");
if (part.state === 4) {
// delete me
part.state = 3;
removePart(part);
}
else if (part.state === 3) {
// being deleted
// pass
}
else if (part.state === 2) {
part.state = 1; // pending: lock
updateStore(part);
}
else {
part.state = 0; // clear lock
}
}
});
};
part.addEventListener('input', function inputChangeListener(_event) {
// debug.log("input changed "+part.value);
setPartStyle(part, undefined, true); // grey out - not synced
log('Input event state ' + part.state + " value '" + part.value + "'");
switch (part.state) {
case 3: // being deleted
return;
case 4: // needs to be deleted
return;
case 2: // needs content updating, we know
return;
case 1:
part.state = 2; // lag we need another patch
return;
case 0:
case undefined:
// Debounce: wait for a pause in typing before sending PATCH
if (inputDebounceTimer !== null)
clearTimeout(inputDebounceTimer);
inputDebounceTimer = setTimeout(() => {
inputDebounceTimer = null;
if (part.state === 0 || part.state === undefined) {
part.state = 1; // being updated
updateStore(part);
}
}, 400);
}
}); // listener
}; // addlisteners
// @@ TODO Need to research before as it appears to be used as an Element and a boolean
const newPartAfter = function (tr1, chunk, before) {
// @@ take chunk and add listeners
let text = kb.any(chunk, ns.sioc('content'));
text = text ? text.value : '';
const tr = dom.createElement('tr');
if (before) {
table.insertBefore(tr, tr1);
}
else {
// after
if (tr1 && tr1.nextSibling) {
table.insertBefore(tr, tr1.nextSibling);
}
else {
table.appendChild(tr);
}
}
const part = tr.appendChild(dom.createElement('input'));
part.subject = chunk;
part.setAttribute('type', 'text');
part.value = text;
if (me) {
setPartStyle(part, '');
addListeners(part, chunk);
}
else {
setPartStyle(part, 'color: #222; background-color: #fff');
log("Note can't add listeners - not logged in");
}
return part;
};
/* @@ TODO we need to look at indent, it can be a Number or an Object this doesn't seem correct.
*/
const newChunk = function (ele, before) {
// element of chunk being split
const kb = store;
let indent = 0;
let queueProperty = null;
let here, prev, next, queue, tr1;
if (ele) {
if (ele.tagName.toLowerCase() !== 'input') {
log('return pressed when current document is: ' + ele.tagName);
}
here = ele.subject;
indent = kb.any(here, PAD('indent'));
indent = indent ? Number(indent.value) : 0;
if (before) {
prev = kb.any(undefined, PAD('next'), here);
next = here;
queue = prev;
queueProperty = 'newlinesAfter';
}
else {
prev = here;
next = kb.any(here, PAD('next'));
queue = next;
queueProperty = 'newlinesBefore';
}
tr1 = ele.parentNode;
}
else {
prev = subject;
next = subject;
tr1 = undefined;
}
const chunk = newThing(padDoc);
const label = chunk.uri.slice(-4);
const del = [st(prev, PAD('next'), next, padDoc)];
const ins = [
st(prev, PAD('next'), chunk, padDoc),
st(chunk, PAD('next'), next, padDoc),
st(chunk, ns.dc('author'), me, padDoc),
st(chunk, ns.sioc('content'), '', padDoc)
];
if (indent > 0) {
// Do not inherit
ins.push(st(chunk, PAD('indent'), indent, padDoc));
}
log(' Fresh chunk ' + label + ' proposed');
if (!updater) {
throw new Error('no updater');
}
updater.update(del, ins, function (uri, ok, errorBody, _xhr) {
if (!ok) {
// alert("Error writing new line " + label + ": " + errorBody);
log(' ERROR writing new line ' + label + ': ' + errorBody);
}
else {
const newPart = newPartAfter(tr1, chunk, before);
setPartStyle(newPart);
newPart.focus(); // Note this is delayed
if (queueProperty) {
log(' Fresh chunk ' +
label +
' updated, queue = ' +
queue[queueProperty]);
queue[queueProperty] -= 1;
if (queue[queueProperty] > 0) {
log(' Implementing queued newlines = ' + next.newLinesBefore);
newChunk(newPart, before);
}
}
}
});
};
const consistencyCheck = function () {
const found = {};
let failed = 0;
function complain2(msg) {
complain(msg);
failed++;
}
if (!kb.the(subject, PAD('next'))) {
complain2('No initial next pointer');
return false; // can't do linked list
}
// var chunk = kb.the(subject, PAD('next'))
let prev = subject;
let chunk;
for (;;) {
chunk = kb.the(prev, PAD('next'));
if (!chunk) {
complain2('No next pointer from ' + prev);
}
if (chunk.sameTerm(subject)) {
break;
}
prev = chunk;
const label = chunk.uri.split('#')[1];
if (found[chunk.uri]) {
complain2('Loop!');
return false;
}
found[chunk.uri] = true;
let k = kb.each(chunk, PAD('next')).length;
if (k !== 1) {
complain2('Should be 1 not ' + k + ' next pointer for ' + label);
}
k = kb.each(chunk, PAD('indent')).length;
if (k > 1) {
complain2('Should be 0 or 1 not ' + k + ' indent for ' + label);
}
k = kb.each(chunk, ns.sioc('content')).length;
if (k !== 1) {
complain2('Should be 1 not ' + k + ' contents for ' + label);
}
k = kb.each(chunk, ns.dc('author')).length;
if (k !== 1) {
complain2('Should be 1 not ' + k + ' author for ' + label);
}
const sts = kb.statementsMatching(undefined, ns.sioc('contents'));
sts.forEach(function (st) {
if (!found[st.subject.value]) {
complain2('Loose chunk! ' + st.subject.value);
}
});
}
return !failed;
};
// Ensure that the display matches the current state of the
// @@ TODO really need to refactor this so that we don't need to cast types
const sync = function () {
// var first = kb.the(subject, PAD('next'))
if (kb.each(subject, PAD('next')).length !== 1) {
const msg = 'Pad: Inconsistent data - NEXT pointers: ' +
kb.each(subject, PAD('next')).length;
log(msg);
if (options.statusArea) {
;
options.statusArea.textContent +=
msg;
}
return;
}
let row;
// First see which of the logical chunks have existing physical manifestations
const manif = [];
// Find which lines correspond to existing chunks
for (let chunk = kb.the(subject, PAD('next')); !chunk.sameTerm(subject); chunk = kb.the(chunk, PAD('next'))) {
for (let i = 0; i < table.children.length; i++) {
const tr = table.children[i];
if (tr.firstChild) {
if (tr.firstChild.subject.sameTerm(chunk)) {
manif[chunk.uri] = tr.firstChild;
}
}
}
}
// Remove any deleted lines
for (let i = table.children.length - 1; i >= 0; i--) {
row = table.children[i];
if (!manif[row.firstChild.subject.uri]) {
table.removeChild(row);
}
}
// Insert any new lines and update old ones
row = table.firstChild; // might be null
for (let chunk = kb.the(subject, PAD('next')); !chunk.sameTerm(subject); chunk = kb.the(chunk, PAD('next'))) {
const text = kb.any(chunk, ns.sioc('content')).value;
// superstitious -- don't mess with unchanged input fields
// which may be selected by the user
if (row && manif[chunk.uri]) {
const part = row.firstChild;
if (text !== part.value) {
part.value = text;
}
setPartStyle(part);
part.state = 0; // Clear the state machine
delete part.lastSent; // DEBUG ONLY
row = row.nextSibling;
}
else {
newPartAfter(row, chunk, true); // actually before
}
}
};
// Refresh the DOM tree
const refreshTree = function (root) {
if (root.refresh) {
root.refresh();
return;
}
for (let i = 0; i < root.children.length; i++) {
refreshTree(root.children[i]);
}
};
let reloading = false;
const checkAndSync = function () {
log(' reloaded OK');
clearStatus();
if (!consistencyCheck()) {
complain('CONSISTENCY CHECK FAILED');
}
else {
refreshTree(table);
}
};
const reloadAndSync = function () {
if (reloading) {
log(' Already reloading - stop');
return; // once only needed
}
reloading = true;
let retryTimeout = 1000; // ms
const tryReload = function () {
log('try reload - timeout = ' + retryTimeout);
if (!updater) {
throw new Error('no updater');
}
updater.reload(updater.store, padDoc, function (ok, message, xhr) {
reloading = false;
if (ok) {
checkAndSync();
}
else {
if (xhr.status === 0) {
complain('Network error refreshing the pad. Retrying in ' +
retryTimeout / 1000);
reloading = true;
retryTimeout = retryTimeout * 2;
setTimeout(tryReload, retryTimeout);
}
else {
complain('Error ' +
xhr.status +
'refreshing the pad:' +
message +
'. Stopped. ' +
padDoc);
}
}
});
};
tryReload();
};
table.refresh = sync; // Catch downward propagating refresh events
table.reloadAndSync = reloadAndSync;
if (!me)
log('Warning: must be logged in for pad to be edited');
if (exists) {
log('Existing pad.');
if (consistencyCheck()) {
sync();
if (kb.holds(subject, PAD('next'), subject)) {
// Empty list untenable
newChunk(); // require at least one line
}
}
else {
log((table.textContent = 'Inconsistent data. Abort'));
}
}
else {
// Make new pad
log('No pad exists - making new one.');
const insertables = [
st(subject, ns.rdf('type'), PAD('Notepad'), padDoc),
st(subject, ns.dc('author'), me, padDoc),
st(subject, ns.dc('created'), new Date(), padDoc),
st(subject, PAD('next'), subject, padDoc)
];
if (!updater) {
throw new Error('no updater');
}
updater.update([], insertables, function (uri, ok, errorBody) {
if (!ok) {
complain(errorBody || '');
}
else {
log('Initial pad created');
newChunk(); // Add a first chunck
// getResults();
}
});
}
return table;
}
/**
* Get the chunks of the notepad
* They are stored in a RDF linked list
*/
// @ignore exporting this only for the unit test
export function getChunks(subject, kb) {
const chunks = [];
for (let chunk = kb.the(subject, PAD('next')); !chunk.sameTerm(subject); chunk = kb.the(chunk, PAD('next'))) {
chunks.push(chunk);
}
return chunks;
}
/**
* Encode content to be put in XML or HTML elements
*/
// @ignore exporting this only for the unit test
export function xmlEncode(str) {
return str.replace('&', '&').replace('<', '<').replace('>', '>');
}
/**
* Convert a notepad to HTML
* @param { } pad - the notepad
* @param {store} pad - the data store
*/
export function notepadToHTML(pad, kb) {
const chunks = getChunks(pad, kb);
let html = '<html>\n <head>\n';
const title = kb.anyValue(pad, ns.dct('title'));
if (title) {
html += ` <title>${xmlEncode(title)}</title>\n`;
}
html += ' </head>\n <body>\n';
let level = 0;
function increaseLevel(indent) {
for (; level < indent; level++) {
html += '<ul>\n';
}
}
function decreaseLevel(indent) {
for (; level > indent; level--) {
html += '</ul>\n';
}
}
chunks.forEach((chunk) => {
const indent = kb.anyJS(chunk, PAD('indent'));
const rawContent = kb.anyJS(chunk, ns.sioc('content'));
if (!rawContent)
return; // seed chunk is dummy
const content = xmlEncode(rawContent);
if (indent < 0) {
// negative indent levels represent heading levels
decreaseLevel(0);
const h = indent >= -3 ? 4 + indent : 1; // -1 -> h4, -2 -> h3
html += `\n<h${h}>${content}</h${h}>\n`;
}
else {
// >= 0
if (indent > 0) {
// Lists
decreaseLevel(indent);
increaseLevel(indent);
html += `<li>${content}</li>\n`;
}
else {
// indent 0
decreaseLevel(indent);
html += `<p>${content}</p>\n`;
}
}
}); // foreach chunk
// At the end decreaseLevel any open ULs
decreaseLevel(0);
html += ' </body>\n</html>\n';
return html;
}
//# sourceMappingURL=pad.js.map