diff --git a/i18n/en.json b/i18n/en.json
index 530b9a5..59a42a6 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1,36 +1,43 @@
{
"@metadata": {
"authors": [
"Lucas Werkmeister"
]
},
"tool-name": "Wikidata Image Positions",
"skip-to-main-content": "Skip to main content",
"nav-documentation": "Documentation",
"nav-toolforge": "Wikimedia Toolforge",
"nav-source-code": "Source code",
"nav-login": "Log in",
"nav-logged-in": "{{GENDER:$2|Logged in}} as $1.",
"index-paragraph-1": "This tool shows $1 qualifiers on $2 or $3 statements as areas on the relevant image.",
"index-paragraph-2": "For statements on [$1 Wikidata] items, they are shown on the item’s $2 (or other property). For statements on [$3 Structured Data on Commons] files, they are shown on the file itself.",
"index-heading-by-item-and-property": "By item and property",
"index-label-item-id": "Item ID",
"index-placeholder-item-id": "$1 or $2 or similar",
"index-label-property-id-optional": "Property ID (optional)",
"index-button-load": "Load",
"index-button-preview-iiif": "Preview IIIF",
"index-button-iiif-manifest": "IIIF Manifest",
"index-heading-by-file": "By file",
"index-label-file-title": "File title",
"index-placeholder-file-title": "$1 or $2 or $3 or similar",
"index-heading-by-iiif": "By IIIF region and property",
"index-label-iiif-region": "IIIF region",
"image-scale": "Image scale: ",
"image-depicted-without-region": "Depicted with no region specified:",
"image-named-places-on-map-without-region": "Named places on map with no region specified:",
+ "image-button-add-region": "add region",
+ "image-button-use-region": "use this region",
+ "image-button-cancel": "cancel",
+ "image-button-editing-statement": "editing statement…",
+ "image-button-edit-region": "Edit a region",
+ "image-button-select-region": "Select (click) a region to edit",
+ "image-button-loading": "loading…",
"alert-not-logged-in": "You are not logged in, so you can only view existing regions and statements. To add new statements or regions or edit existing ones, [$1 log in].",
"alert-noscript": "JavaScript is disabled, so you can only view existing regions and statements. To add new statements or regions or edit existing ones, enable JavaScript.",
"settings": "Settings",
"settings-label-interface-language-code": "User interface language",
"settings-save": "Save"
}
diff --git a/i18n/qqq.json b/i18n/qqq.json
index c2a798e..86c6233 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -1,38 +1,45 @@
{
"@metadata": {
"authors": [
"Amire80",
"Lucas Werkmeister",
"Siebrand"
]
},
"tool-name": "The name of the tool. This message is currently used for the page title, navigation bar / menu, and heading on the index page. Those uses could later be split up into separate messages if necessary.",
"skip-to-main-content": "Text for a link that allows users of assistive technologies to skip unimportant parts of the page (the navigation section).",
"nav-documentation": "Text for a link to the tool’s documentation in its navigation bar / menu.\n{{Identical|Documentation}}",
"nav-toolforge": "Text for a link to the tool’s hosting platform, Wikimedia Toolforge, in its navigation bar / menu.",
"nav-source-code": "Text for a link to the tool’s source code in its navigation bar / menu.",
"nav-login": "Text for a link to log into the tool in its navigation bar / menu.\n{{Identical|Log in}}",
"nav-logged-in": "Indicator in the navbar that the user is logged in.\n\nParameters:\n* $1 – the user name as a link to the user page.\n* $2 – the user name as plain text. Can be used with the GENDER magic word. If GENDER is not needd, the translation can leave out $2 completely.",
"index-paragraph-1": "First paragraph on the tool’s index page, describing the tool.\n\nParameters:\n* $1 - link to the [[:d:Property:P2677|relative position within image]] property on Wikidata (with localized label)\n* $2 - link to the [[:d:Property:P180|depicts]] property on Wikidata (with localized label)\n* $3 - link to the [[:d:Property:P9664|named place on map]] property on Wikidata (with localized label)",
"index-paragraph-2": "Second paragraph of the tool’s index page, further describing how the tool works.\n\nParameters:\n* $1 - URL for linking to Wikidata\n* $2 - link to the [[:d:Property:P18|image]] property on Wikidata (with localized label)\n* $3 - URL for linking to Structured Data on Commons",
"index-heading-by-item-and-property": "Heading on the tool’s index page. “Item” and “property” refer to Wikidata entities ({{msg-mw|wikibase-entity-item}}, {{msg-mw|wikibase-entity-property}}). “By” has the same meaning as in e.g. {{msg-mw|special-itembytitle}}.",
"index-label-item-id": "Label for an input for a Wikidata item ID.",
"index-placeholder-item-id": "Placeholder for an input for a Wikidata item ID.\n\nParameters:\n* $1 - one possible format (item ID)\n* $2 - another possible format (full item URL)",
"index-label-property-id-optional": "Label for an optional input for a Wikidata property ID.",
"index-button-load": "Label for several buttons to load data.",
"index-button-preview-iiif": "Label for a button to preview an IIIF manifest on another tool. IIIF ([[:w:International Image Interoperability Framework|International Image Interoperability Framework]]) is not usually translated.",
"index-button-iiif-manifest": "Label for a button to generate a IIIF manifest. IIIF ([[:w:International Image Interoperability Framework|International Image Interoperability Framework]]) is not usually translated.",
"index-heading-by-file": "Heading on the tool’s index page. “File” refers to media files on Wikimedia Commons. “By” has the same meaning as in e.g. {{msg-mw|special-itembytitle}}.",
"index-label-file-title": "Label for an input for a Wikimedia Commons file.",
"index-placeholder-file-title": "Placeholder for an input for a Wikimedia Commons file.\n\nParameters:\n* $1 - one possible format (file name)\n* $2 - another possible format (MediaInfo entity ID)\n* $3 - another possible format (full file URL)",
"index-heading-by-iiif": "Heading on the tool’s index page. IIIF ([[:w:International Image Interoperability Framework|International Image Interoperability Framework]]) is not usually translated. “Property” refers to Wikidata entities ({{msg-mw|wikibase-entity-property}}). “By” has the same meaning as in e.g. {{msg-mw|special-itembytitle}}.",
"index-label-iiif-region": "Label for an input for a IIIF region. The whole message is wrapped in a link to the relevant IIIF specification. IIIF ([[:w:International Image Interoperability Framework|International Image Interoperability Framework]]) is not usually translated.",
"image-scale": "Label for an input (or set of inputs) that let the user scale the size of the image between 0× and 5× (a kind of “zoom in” or “zoom out”).",
"image-depicted-without-region": "Message above a list of one or more items that are linked using the [[:d:Property:P2677|depicts]] property on Wikidata, but without a [[:d:Property:P2677|relative position within image]] qualifier. The translation should follow the label of the “depicts” property on Wikidata. (See also {{msg-wm|wikidata-image-positions-image-named-places-on-map-without-region}}.)",
"image-named-places-on-map-without-region": "Message above a list of one or more items that are linked using the [[:d:Property:P9664|named place on map]] property on Wikidata, but without a [[:d:Property:P2677|relative position within image]] qualifier. The translation should follow the label of the “named place on map” property on Wikidata. (See also {{msg-wm|wikidata-image-positions-image-depicted-without-region}}.)",
+ "image-button-add-region": "Label for a button to add a region ([[:d:Property:P2677|relative position within image]] qualifier) to a depicted item on the image.",
+ "image-button-use-region": "Label for a button to stop editing the image region and publish it.",
+ "image-button-cancel": "Label for a button to cancel adding or editing a region.\n{{Identical|Cancel}}",
+ "image-button-editing-statement": "Label for a button while a statement is being edited to add a [[:d:Property:P2677|relative position within image]] qualifier; this label is shown after the button has been clicked, so it’s really an informational message, not a call to action. “Statement” refers to a Wikibase statement, compare e.g. {{msg-mw|wikibase-statementgrouplistview-add}}, {{msg-mw|wikibase-statementsection-statements}}, and [[Template:Identical/Statement]].",
+ "image-button-edit-region": "Label for a button to select one of the existing regions on the image for editing.",
+ "image-button-select-region": "Label for the button from {{msg-wm|wikidata-image-positions-image-button-edit-region}} after it has been clicked, prompting the user to click one of the existing regions by clicking on it.",
+ "image-button-loading": "Label for a button while data is being loaded in the background. (The button isn’t actionable during this time, so this is more an informational message.)",
"alert-not-logged-in": "Message shown when the user is not logged into the tool. The parts in <noscript> are additionally only shown to users who have JavaScript disabled, and effectively duplicate the information from {{msg-wm|wikidata-image-positions-alert-noscript}}.\n\nParameters:\n* $1 - URL for the login link",
"alert-noscript": "Message shown when the user is logged in, but has JavaScript disabled. The entire message is wrapped in <noscript> by the tool; compare also {{msg-wm|wikidata-image-positions-alert-not-logged-in}}.",
"settings": "The tool’s settings (preferences, options, etc.). This message is currently used for the heading on the settings page as well as the link in the navigation bar / menu; those uses could later be split up into separate messages if necessary.",
"settings-label-interface-language-code": "Label for one of the tool’s settings, for the language the tool’s user interface is in.",
"settings-save": "Label for the button to save the settings."
}
diff --git a/static/image-edit.js b/static/image-edit.js
index 2abf9a3..5d19c42 100644
--- a/static/image-edit.js
+++ b/static/image-edit.js
@@ -1,547 +1,548 @@
import { createApp } from 'vue';
import * as codex from 'codex';
import * as codexIcons from 'codex-icons';
import Session, { set } from 'm3api/browser.js';
function setup() {
'use strict';
const csrfTokenElement = document.getElementById('csrf_token'),
baseUrl = document.querySelector('link[rel=index]').href.replace(/\/$/, ''),
mainDataset = document.getElementsByTagName('main')[0].dataset,
+ translations = JSON.parse(mainDataset.translations),
depictedPropertiesLabels = JSON.parse(mainDataset.depictedPropertiesLabels),
depictedPropertiesMessages = JSON.parse(mainDataset.depictedPropertiesMessages);
/** Make a key event handler that calls the given callback when Esc is pressed. */
function onEscape(callback) {
return function(eKey) {
if (eKey.key === 'Escape') {
return callback.apply(this, arguments);
}
};
}
function addEditButtons() {
document.querySelectorAll('.wd-image-positions--depicted-without-region').forEach(addEditButton);
}
function addEditButton(element) {
const entity = element.closest('.wd-image-positions--entity'),
depictedId = element.firstChild.dataset.entityId,
scaleInput = entity.querySelector('.wd-image-positions--scale'),
wrapper = entity.querySelector('.wd-image-positions--wrapper'),
image = wrapper.firstElementChild;
const button = document.createElement('button');
button.type = 'button';
button.classList.add('btn', 'btn-secondary', 'btn-sm', 'ms-2');
- button.textContent = 'add region';
+ button.textContent = translations['image-button-add-region'];
button.addEventListener('click', onClick);
element.append(button);
let cropper = null;
let doneCallback = null;
const onKeyDown = onEscape(cancelEditing);
let cancelButton = null;
function onClick() {
if (cropper === null) {
- button.textContent = 'loading...';
+ button.textContent = translations['image-button-loading'];
wrapper.classList.add('wd-image-positions--active');
image.classList.add('wd-image-positions--active');
button.classList.add('wd-image-positions--active');
scaleInput.disabled = true;
doneCallback = ensureImageCroppable(image);
cropper = new Cropper(image.firstElementChild, {
viewMode: 2,
movable: false,
rotatable: true, // we don’t rotate the image ourselves, but this allows cropper.js to respect JPEG orientation
scalable: false,
zoomable: false,
checkCrossOrigin: false,
autoCrop: false,
ready: function() {
- button.textContent = 'use this region';
+ button.textContent = translations['image-button-use-region'];
cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.classList.add('btn', 'btn-secondary', 'btn-sm', 'wd-image-positions--active', 'ms-2');
- cancelButton.textContent = 'cancel';
+ cancelButton.textContent = translations['image-button-cancel'];
cancelButton.addEventListener('click', cancelEditing);
element.append(cancelButton);
},
});
document.addEventListener('keydown', onKeyDown);
} else {
- if (button.textContent === 'loading...') {
+ if (button.textContent === translations['image-button-loading']) {
return;
}
const cropData = cropper.getData();
if (!cropData.width || !cropData.height) {
window.alert('Please select a region first. (Drag the mouse across an area, then adjust as needed.)');
return;
}
const depicted = document.createElement('div');
depicted.classList.add('wd-image-positions--depicted')
const propertyId = [...element.closest('.wd-image-positions--depicteds-without-region').classList]
.filter(klass => klass.startsWith('wd-image-positions--depicteds-without-region__'))
.map(klass => klass.slice('wd-image-positions--depicteds-without-region__'.length))[0];
depicted.classList.add(`wd-image-positions--depicted__${propertyId}`)
if (depictedId !== undefined) {
depicted.dataset.entityId = depictedId;
}
depicted.dataset.statementId = element.dataset.statementId;
depicted.append(element.firstChild.cloneNode(true));
image.append(depicted);
- button.textContent = 'editing statement…';
+ button.textContent = translations['image-button-editing-statement'];
const subject = { id: entity.dataset.entityId, domain: entity.dataset.entityDomain };
saveCropper(subject, image, depicted, cropper).then(
function() {
element.remove();
if (image.querySelectorAll('.wd-image-positions--depicted').length === 1) {
addEditRegionButton(entity);
}
},
function() {
element.remove();
},
).then(doneCallback).finally(() => {
document.removeEventListener('keydown', onKeyDown);
scaleInput.disabled = false;
});
cropper = null;
}
}
function cancelEditing() {
cropper.destroy();
cropper = null;
doneCallback();
wrapper.classList.remove('wd-image-positions--active');
image.classList.remove('wd-image-positions--active');
document.removeEventListener('keydown', onKeyDown);
- button.textContent = 'add region';
+ button.textContent = translations['image-button-add-region'];
button.classList.remove('wd-image-positions--active');
scaleInput.disabled = false;
if (cancelButton !== null) {
cancelButton.remove();
cancelButton = null;
}
}
}
/**
* Ensure that the image element is suitable for cropper.js,
* by temporarily changing its src to the last (presumed highest-resolution) srcset entry.
* The srcset is assumed to contain PNG/JPG thumbs,
* whereas the src may be in an unsupported image format, such as TIFF.
*
* @param {HTMLElement} image The .wd-image-positions--image containing the
* (*not* the
itself)
* @return {function} Callback to restore the image to its original src,
* to be called after the cropper has been destroyed.
*/
function ensureImageCroppable(image) {
const img = image.querySelector('img'),
originalSrc = img.src;
if (!/\.(?:jpe?g|png|gif)$/i.test(originalSrc)) {
img.src = img.srcset.split(' ').slice(-2)[0];
}
return function() {
img.src = originalSrc;
};
}
/**
* Save the cropper as a region qualifier for the depicted.
*
* @param {{ id: string, domain: string}} subject The subject entity
* @param {HTMLElement} image The .wd-image-positions--image (*not* the
)
* @param {HTMLElement} depicted The .wd-image-positions--depicted,
* with a dataset containing a statementId, optional entityId and optional qualifierHash
* @param {Cropper} cropper The cropper (will be destroyed)
* @return {Promise}
*/
function saveCropper(subject, image, depicted, cropper) {
const wrapper = image.parentElement;
wrapper.classList.remove('wd-image-positions--active');
image.classList.remove('wd-image-positions--active');
const cropData = cropper.getData(),
canvasData = cropper.getCanvasData(),
x = 100 * cropData.x / canvasData.naturalWidth,
y = 100 * cropData.y / canvasData.naturalHeight,
w = 100 * cropData.width / canvasData.naturalWidth,
h = 100 * cropData.height / canvasData.naturalHeight;
// note: the browser rounds the percentages a bit,
// and we’ll use the rounded values for the IIIF region
depicted.style.left = `${x}%`;
depicted.style.top = `${y}%`;
depicted.style.width = `${w}%`;
depicted.style.height = `${h}%`;
cropper.destroy();
function pct(name) {
return depicted.style[name].replace('%', '');
}
const iiifRegion = `pct:${pct('left')},${pct('top')},${pct('width')},${pct('height')}`;
const statementId = depicted.dataset.statementId,
qualifierHash = depicted.dataset.qualifierHash,
csrfToken = csrfTokenElement.textContent,
formData = new FormData();
formData.append('statement_id', statementId);
if (qualifierHash) {
formData.append('qualifier_hash', qualifierHash);
}
formData.append('iiif_region', iiifRegion);
formData.append('_csrf_token', csrfToken);
return fetch(`${baseUrl}/api/v2/add_qualifier/${subject.domain}`, {
method: 'POST',
body: formData,
credentials: 'include',
}).then(response => {
if (response.ok) {
return response.json().then(json => {
depicted.dataset.qualifierHash = json.qualifier_hash;
});
} else {
return response.text().then(text => {
window.alert(`An error occurred:\n\n${text}\n\nThe region drawn is ${iiifRegion}, if you want to add it manually.`);
throw new Error('Saving failed');
});
}
});
}
function addEditRegionButtons() {
document.querySelectorAll('.wd-image-positions--entity').forEach(addEditRegionButton);
}
function addEditRegionButton(entityElement) {
const wrapper = entityElement.querySelector('.wd-image-positions--wrapper'),
image = wrapper.firstElementChild;
if (!image.querySelector('.wd-image-positions--depicted')) {
return;
}
const scaleInput = entityElement.querySelector('.wd-image-positions--scale');
const button = document.createElement('button');
button.type = 'button';
button.classList.add('btn', 'btn-secondary');
- button.textContent = 'Edit a region';
+ button.textContent = translations['image-button-edit-region'];
button.addEventListener('click', addEditRegionListeners);
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.classList.add('btn', 'btn-secondary', 'wd-image-positions--active', 'ms-2');
- cancelButton.textContent = 'cancel';
+ cancelButton.textContent = translations['image-button-cancel'];
const buttonWrapper = document.createElement('div');
buttonWrapper.append(button);
// cancelButton is not appended yet
entityElement.append(buttonWrapper);
const fieldSet = entityElement.querySelector('fieldset');
if (fieldSet) {
entityElement.append(fieldSet); // move after buttonWrapper
}
let onKeyDown = null;
function addEditRegionListeners() {
- button.textContent = 'Select a region to edit';
+ button.textContent = translations['image-button-select-region'];
button.classList.add('wd-image-positions--active');
for (const depicted of entityElement.querySelectorAll('.wd-image-positions--depicted')) {
depicted.addEventListener('click', editRegion);
}
button.removeEventListener('click', addEditRegionListeners);
onKeyDown = onEscape(cancelSelectRegion);
document.addEventListener('keydown', onKeyDown);
buttonWrapper.append(cancelButton);
cancelButton.addEventListener('click', cancelSelectRegion);
}
function editRegion(event) {
event.preventDefault();
wrapper.classList.add('wd-image-positions--active');
image.classList.add('wd-image-positions--active');
scaleInput.disabled = true;
for (const depicted of entityElement.querySelectorAll('.wd-image-positions--depicted')) {
depicted.removeEventListener('click', editRegion);
}
const depicted = event.target.closest('.wd-image-positions--depicted');
document.removeEventListener('keydown', onKeyDown);
cancelButton.removeEventListener('click', cancelSelectRegion);
onKeyDown = onEscape(cancelEditRegion);
document.addEventListener('keydown', onKeyDown);
cancelButton.addEventListener('click', cancelEditRegion);
const doneCallback = ensureImageCroppable(image);
const cropper = new Cropper(image.firstElementChild, {
viewMode: 2,
movable: false,
rotatable: true, // we don’t rotate the image ourselves, but this allows cropper.js to respect JPEG orientation
scalable: false,
zoomable: false,
checkCrossOrigin: false,
ready: function() {
const canvasData = cropper.getCanvasData();
cropper.setData({
x: Math.round(parseFloat(depicted.style.left) * canvasData.naturalWidth / 100),
y: Math.round(parseFloat(depicted.style.top) * canvasData.naturalHeight / 100),
width: Math.round(parseFloat(depicted.style.width) * canvasData.naturalWidth / 100),
height: Math.round(parseFloat(depicted.style.height) * canvasData.naturalHeight / 100),
});
- button.textContent = 'use this region';
+ button.textContent = translations['image-button-use-region'];
button.addEventListener('click', doEditRegion);
},
});
function doEditRegion() {
button.removeEventListener('click', doEditRegion);
- button.textContent = 'editing statement…';
+ button.textContent = translations['image-button-editing-statement'];
const subject = { id: entityElement.dataset.entityId, domain: entityElement.dataset.entityDomain };
saveCropper(subject, image, depicted, cropper).then(
function() {
- button.textContent = 'Edit a region';
+ button.textContent = translations['image-button-edit-region'];
button.classList.remove('wd-image-positions--active');
button.addEventListener('click', addEditRegionListeners);
},
function() {
- button.textContent = 'Edit a region';
+ button.textContent = translations['image-button-edit-region'];
button.classList.remove('wd-image-positions--active');
button.addEventListener('click', addEditRegionListeners);
},
).then(doneCallback).finally(() => {
document.removeEventListener('keydown', onKeyDown);
scaleInput.disabled = false;
cancelButton.remove();
});
}
function cancelEditRegion() {
cropper.destroy();
doneCallback();
wrapper.classList.remove('wd-image-positions--active');
image.classList.remove('wd-image-positions--active');
button.removeEventListener('click', doEditRegion);
- button.textContent = 'Edit a region';
+ button.textContent = translations['image-button-edit-region'];
button.addEventListener('click', addEditRegionListeners);
button.classList.remove('wd-image-positions--active');
document.removeEventListener('keydown', onKeyDown);
scaleInput.disabled = false;
cancelButton.remove();
}
}
function cancelSelectRegion() {
for (const depicted of entityElement.querySelectorAll('.wd-image-positions--depicted')) {
depicted.removeEventListener('click', editRegion);
}
- button.textContent = 'Edit a region';
+ button.textContent = translations['image-button-edit-region'];
button.addEventListener('click', addEditRegionListeners);
button.classList.remove('wd-image-positions--active');
document.removeEventListener('keydown', onKeyDown);
cancelButton.remove();
}
}
function addNewDepictedForm(entityElement) {
const session = new Session( 'www.wikidata.org', {
formatversion: 2,
origin: '*',
}, {
userAgent: 'Wikidata-Image-Positions (https://wd-image-positions.toolforge.org/)',
} );
const entity = entityElement.closest('.wd-image-positions--entity'),
subjectId = entity.dataset.entityId,
subjectDomain = entity.dataset.entityDomain,
newDepictedFormRoot = document.createElement('div');
entityElement.append(newDepictedFormRoot);
createApp({
template: `