diff --git a/edit.html b/edit.html index 6d92f359..e1dd9fce 100644 --- a/edit.html +++ b/edit.html @@ -24,6 +24,7 @@ + diff --git a/edit/applies-to-line-widget.js b/edit/applies-to-line-widget.js new file mode 100644 index 00000000..923b350f --- /dev/null +++ b/edit/applies-to-line-widget.js @@ -0,0 +1,415 @@ +/* global regExpTester */ +'use strict'; + +function createAppliesToLineWidget(cm) { + const APPLIES_TYPE = [ + [t('appliesUrlOption'), 'url'], + [t('appliesUrlPrefixOption'), 'url-prefix'], + [t('appliesDomainOption'), 'domain'], + [t('appliesRegexpOption'), 'regexp'] + ]; + const THROTTLE_DELAY = 400; + let widgets = []; + let timer; + let fromLine; + let toLine; + let style; + let isInit; + + return {toggle}; + + function toggle(state = !isInit) { + if (!isInit && state) { + init(); + } else if (isInit && !state) { + uninit(); + } + } + + function init() { + isInit = true; + + style = getComputedStyle(cm.getGutterElement()); + fromLine = null; + toLine = null; + + cm.on('change', onChange); + cm.on('optionChange', onOptionChange); + + // is it possible to avoid flickering? + window.addEventListener('load', updateStyle); + + update(); + } + + function uninit() { + isInit = false; + + widgets.forEach(clearWidget); + widgets.length = 0; + cm.off('change', onChange); + cm.off('optionChange', onOptionChange); + window.removeEventListener('load', updateStyle); + } + + function onChange(cm, {from, to, origin}) { + if (origin === 'appliesTo') { + return; + } + if (fromLine === null || toLine === null) { + fromLine = from.line; + toLine = to.line; + } else { + fromLine = Math.min(fromLine, from.line); + toLine = Math.max(toLine, to.line); + } + clearTimeout(timer); + timer = setTimeout(update, THROTTLE_DELAY); + } + + function onOptionChange(cm, option) { + if (option === 'theme') { + updateStyle(); + } + } + + function update() { + cm.operation(doUpdate); + } + + function updateStyle() { + style = getComputedStyle(cm.getGutterElement()); + widgets.forEach(setWidgetStyle); + } + + function setWidgetStyle(widget) { + let borderStyle = ''; + if (style.borderRightWidth !== '0px') { + borderStyle = `${style.borderRightWidth} ${style.borderRightStyle} ${style.borderRightColor}`; + } else { + borderStyle = `1px solid ${style.color}`; + } + widget.node.style.backgroundColor = style.backgroundColor; + widget.node.style.borderTop = borderStyle; + widget.node.style.borderBottom = borderStyle; + } + + function doUpdate() { + // find which widgets needs to be update + // some widgets (lines) might be deleted + widgets = widgets.filter(w => w.line.lineNo() !== null); + let i = fromLine === null ? 0 : widgets.findIndex(w => w.line.lineNo() > fromLine) - 1; + let j = toLine === null ? 0 : widgets.findIndex(w => w.line.lineNo() > toLine); + if (i === -2) { + i = widgets.length - 1; + } + if (j < 0) { + j = widgets.length; + } + + // decide search range + const fromIndex = widgets[i] ? cm.indexFromPos({line: widgets[i].line.lineNo(), ch: 0}) : 0; + const toIndex = widgets[j] ? cm.indexFromPos({line: widgets[j].line.lineNo(), ch: 0}) : cm.getValue().length; + + // splice + if (i < 0) { + i = 0; + } + + widgets.splice(i, 0, ...createWidgets(fromIndex, toIndex, widgets.splice(i, j - i))); + + fromLine = null; + toLine = null; + } + + function *createWidgets(start, end, removed) { + let i = 0; + for (const section of findAppliesTo(start, end)) { + while (removed[i] && removed[i].line.lineNo() < section.pos.line) { + clearWidget(removed[i++]); + } + setupMarkers(section); + if (removed[i] && removed[i].line.lineNo() === section.pos.line) { + // reuse old widget + removed[i].section.applies.forEach(apply => { + apply.type.mark.clear(); + apply.value.mark.clear(); + }); + removed[i].section = section; + const newNode = buildElement(section); + removed[i].node.parentNode.replaceChild(newNode, removed[i].node); + removed[i].node = newNode; + setWidgetStyle(removed[i]); + removed[i].changed(); + yield removed[i]; + i++; + continue; + } + // new widget + const widget = cm.addLineWidget(section.pos.line, buildElement(section), { + coverGutter: true, + noHScroll: true, + above: true + }); + widget.section = section; + setWidgetStyle(widget); + yield widget; + } + removed.slice(i).forEach(clearWidget); + } + + function clearWidget(widget) { + widget.clear(); + widget.section.applies.forEach(clearApply); + } + + function clearApply(apply) { + apply.type.mark.clear(); + apply.value.mark.clear(); + apply.mark.clear(); + } + + function setupMarkers({applies}) { + applies.forEach(setupApplyMarkers); + } + + function setupApplyMarkers(apply) { + apply.type.mark = cm.markText( + cm.posFromIndex(apply.type.start), + cm.posFromIndex(apply.type.end), + {clearWhenEmpty: false} + ); + apply.value.mark = cm.markText( + cm.posFromIndex(apply.value.start), + cm.posFromIndex(apply.value.end), + {clearWhenEmpty: false} + ); + apply.mark = cm.markText( + cm.posFromIndex(apply.start), + cm.posFromIndex(apply.end), + {clearWhenEmpty: false} + ); + } + + function buildElement({applies}) { + const el = $element({className: 'applies-to', appendChild: [ + $element({tag: 'label', appendChild: [ + t('appliesLabel'), + // $element({tag: 'svg'}) + ]}), + $element({ + tag: 'ul', + className: 'applies-to-list', + appendChild: applies.map(makeInputEl) + }) + ]}); + if (!$('li', el)) { + $('ul', el).appendChild($element({ + tag: 'li', + className: 'applies-to-everything', + textContent: t('appliesToEverything') + })); + } + return el; + + function makeInputEl(apply) { + const el = $element({tag: 'li', appendChild: makeInput(apply)}); + el.dataset.type = apply.type.text; + el.addEventListener('change', e => { + if (e.target.classList.contains('applies-type')) { + el.dataset.type = apply.type.text; + } + }); + return el; + } + + function makeInput(apply) { + const typeInput = $element({ + tag: 'select', + className: 'applies-type', + appendChild: APPLIES_TYPE.map(([label, value]) => $element({ + tag: 'option', + value: value, + textContent: label + })), + onchange(e) { + applyChange(apply.type, e.target.value); + } + }); + typeInput.value = apply.type.text; + let timer; + const valueInput = $element({ + tag: 'input', + className: 'applies-value', + value: apply.value.text, + oninput(e) { + clearTimeout(timer); + timer = setTimeout(applyChange, THROTTLE_DELAY, apply.value, e.target.value); + }, + onfocus: updateRegexpTest + }); + const regexpTestButton = $element({ + tag: 'button', + type: 'button', + className: 'applies-to-regexp-test', + textContent: t('styleRegexpTestButton'), + onclick() { + regExpTester.toggle(); + regExpTester.update([apply.value.text]); + } + }); + const removeButton = $element({ + tag: 'button', + type: 'button', + className: 'applies-to-remove', + textContent: t('appliesRemove'), + onclick(e) { + const i = applies.indexOf(apply); + let repl; + let from; + let to; + if (applies.length < 2) { + alert('Can\'t remove last applies-to'); + return; + } + if (i === 0) { + from = apply.mark.find().from; + to = applies[i + 1].mark.find().from; + repl = ''; + } else if (i === applies.length - 1) { + from = applies[i - 1].mark.find().to; + to = apply.mark.find().to; + repl = ''; + } else { + from = applies[i - 1].mark.find().to; + to = applies[i + 1].mark.find().from; + repl = ', '; + } + cm.replaceRange(repl, from, to, 'appliesTo'); + clearApply(apply); + e.target.closest('li').remove(); + applies.splice(i, 1); + } + }); + const addButton = $element({ + tag: 'button', + type: 'button', + className: 'applies-to-add', + textContent: t('appliesAdd'), + onclick(e) { + const i = applies.indexOf(apply); + const pos = apply.mark.find().to; + const text = `, ${apply.type.text}("")`; + cm.replaceRange(text, pos, pos, 'appliesTo'); + const index = cm.indexFromPos(pos); + const newApply = { + type: { + text: apply.type.text + }, + value: { + text: '' + } + }; + newApply.start = index + 2; + newApply.type.start = newApply.start; + newApply.type.end = newApply.type.start + newApply.type.text.length; + newApply.value.start = newApply.type.end + 2; + newApply.value.end = newApply.value.start + newApply.value.text.length; + newApply.end = newApply.value.end + 2; + setupApplyMarkers(newApply); + applies.splice(i + 1, 0, newApply); + const li = e.target.closest('li'); + li.parentNode.insertBefore(makeInputEl(newApply), li.nextSibling); + } + }); + return [typeInput, valueInput, regexpTestButton, removeButton, addButton]; + + function updateRegexpTest() { + if (apply.type.text === 'regexp') { + const re = apply.value.text.trim(); + if (re) { + regExpTester.update([re]); + } else { + regExpTester.update([]); + } + } + } + + function applyChange(input, newText) { + const range = input.mark.find(); + input.mark.clear(); + cm.replaceRange(newText, range.from, range.to, 'appliesTo'); + input.mark = cm.markText( + range.from, + cm.findPosH( + range.from, + newText.length, + 'char' + ), + {clearWhenEmpty: false} + ); + input.text = newText; + + if (input === apply.type) { + const range = apply.mark.find(); + apply.mark.clear(); + apply.mark = cm.markText( + input.mark.find().from, + range.to, + {clearWhenEmpty: false} + ); + } + + updateRegexpTest(); + } + } + } + + function *findAppliesTo(posStart, posEnd) { + const text = cm.getValue(); + const re = /^[\t ]*@-moz-document\s+/mg; + const applyRe = /^(url|url-prefix|domain|regexp)\(((['"])(?:\\\\|\\\n|\\\3|[^\n])*?\3|[^)\n]*)\)[\s,]*/i; + let preIndex = re.lastIndex = posStart; + let match; + let pos = cm.posFromIndex(preIndex); + while ((match = re.exec(text))) { + if (match.index >= posEnd) { + return; + } + pos = cm.findPosH(pos, match.index - preIndex, 'char'); + const applies = []; + let t = text.slice(re.lastIndex); + let m; + let offset = 0; + while ((m = t.match(applyRe))) { + const apply = { + type: { + text: m[1] + }, + value: { + text: normalizeString(m[2]) + } + }; + apply.type.start = re.lastIndex + offset; + apply.type.end = apply.type.start + apply.type.text.length; + apply.value.start = apply.type.end + (apply.value.text === m[2] ? 1 : 2); + apply.value.end = apply.value.start + apply.value.text.length; + apply.start = apply.type.start; + apply.end = apply.value.end + (apply.value.text === m[2] ? 1 : 2); + applies.push(apply); + t = t.slice(m[0].length); + offset += m[0].length; + } + yield {pos, applies}; + preIndex = match.index; + re.lastIndex = text.length - t.length; + } + } + + function normalizeString(s) { + if (/^(['"])[\s\S]*\1$/.test(s)) { + return s.slice(1, -1); + } + return s; + } +} diff --git a/edit/source-editor.js b/edit/source-editor.js index 7237d36c..490440ce 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -2,7 +2,7 @@ /* global showToggleStyleHelp goBackToManage updateLintReportIfEnabled */ /* global hotkeyRerouter setupAutocomplete setupOptionsExpand */ /* global editors linterConfig updateLinter regExpTester mozParser */ -/* global makeLink */ +/* global makeLink createAppliesToLineWidget */ 'use strict'; function createSourceEditor(style) { @@ -53,12 +53,45 @@ function createSourceEditor(style) { // draw metas info updateMetas(); initHooks(); - initAppliesToReport(cm); + initAppliesToLineWidget(); // setup linter initLint(); initLinterSwitch(); + function initAppliesToLineWidget() { + const PREF_NAME = 'editor.appliesToLineWidget'; + const widget = createAppliesToLineWidget(cm); + const optionEl = buildOption(); + + $('#options').insertBefore(optionEl, $('#options > .option.aligned')); + widget.toggle(prefs.get(PREF_NAME)); + prefs.subscribe([PREF_NAME], (key, value) => { + widget.toggle(value); + optionEl.checked = value; + }); + optionEl.addEventListener('change', e => { + prefs.set(PREF_NAME, e.target.checked); + }); + + function buildOption() { + return $element({className: 'option', appendChild: [ + $element({ + tag: 'input', + type: 'checkbox', + id: PREF_NAME, + checked: prefs.get(PREF_NAME) + }), + $element({ + tag: 'label', + htmlFor: PREF_NAME, + textContent: ' ' + t('appliesLineWidgetLabel'), + title: t('appliesLineWidgetWarning') + }) + ]}); + } + } + function initLinterSwitch() { const linterEl = $('#editor.linter'); cm.on('optionChange', (cm, option) => { @@ -106,446 +139,6 @@ ${section} style.sourceCode = sourceCode; } - function initAppliesToReport(cm) { - const APPLIES_TYPE = [ - [t('appliesUrlOption'), 'url'], - [t('appliesUrlPrefixOption'), 'url-prefix'], - [t('appliesDomainOption'), 'domain'], - [t('appliesRegexpOption'), 'regexp'] - ]; - const THROTTLE_DELAY = 400; - let widgets = []; - let timer; - let fromLine; - let toLine; - let style; - let isInit; - const optionEl = buildOption(); - - $('#options').insertBefore(optionEl, $('#options > .option.aligned')); - - if (prefs.get('editor.appliesToLineWidget')) { - init(); - } - - prefs.subscribe(['editor.appliesToLineWidget'], (key, value) => { - if (!isInit && value) { - init(); - } else if (isInit && !value) { - uninit(); - } - optionEl.checked = value; - }); - - optionEl.addEventListener('change', e => { - prefs.set('editor.appliesToLineWidget', e.target.checked); - }); - - function buildOption() { - return $element({className: 'option', appendChild: [ - $element({ - tag: 'input', - type: 'checkbox', - id: 'editor.appliesToLineWidget', - checked: prefs.get('editor.appliesToLineWidget') - }), - $element({ - tag: 'label', - htmlFor: 'editor.appliesToLineWidget', - textContent: ' ' + t('appliesLineWidgetLabel'), - title: t('appliesLineWidgetWarning') - }) - ]}); - } - - function init() { - isInit = true; - - style = getComputedStyle(cm.getGutterElement()); - fromLine = null; - toLine = null; - - cm.on('change', onChange); - cm.on('optionChange', onOptionChange); - - // is it possible to avoid flickering? - window.addEventListener('load', updateStyle); - - update(); - } - - function uninit() { - isInit = false; - - widgets.forEach(clearWidget); - widgets.length = 0; - cm.off('change', onChange); - cm.off('optionChange', onOptionChange); - window.removeEventListener('load', updateStyle); - } - - function onChange(cm, {from, to, origin}) { - if (origin === 'appliesTo') { - return; - } - if (fromLine === null || toLine === null) { - fromLine = from.line; - toLine = to.line; - } else { - fromLine = Math.min(fromLine, from.line); - toLine = Math.max(toLine, to.line); - } - clearTimeout(timer); - timer = setTimeout(update, THROTTLE_DELAY); - } - - function onOptionChange(cm, option) { - if (option === 'theme') { - updateStyle(); - } - } - - function update() { - cm.operation(doUpdate); - } - - function updateStyle() { - style = getComputedStyle(cm.getGutterElement()); - widgets.forEach(setWidgetStyle); - } - - function setWidgetStyle(widget) { - let borderStyle = ''; - if (style.borderRightWidth !== '0px') { - borderStyle = `${style.borderRightWidth} ${style.borderRightStyle} ${style.borderRightColor}`; - } else { - borderStyle = `1px solid ${style.color}`; - } - widget.node.style.backgroundColor = style.backgroundColor; - widget.node.style.borderTop = borderStyle; - widget.node.style.borderBottom = borderStyle; - } - - function doUpdate() { - // find which widgets needs to be update - // some widgets (lines) might be deleted - widgets = widgets.filter(w => w.line.lineNo() !== null); - let i = fromLine === null ? 0 : widgets.findIndex(w => w.line.lineNo() > fromLine) - 1; - let j = toLine === null ? 0 : widgets.findIndex(w => w.line.lineNo() > toLine); - if (i === -2) { - i = widgets.length - 1; - } - if (j < 0) { - j = widgets.length; - } - - // decide search range - const fromIndex = widgets[i] ? cm.indexFromPos({line: widgets[i].line.lineNo(), ch: 0}) : 0; - const toIndex = widgets[j] ? cm.indexFromPos({line: widgets[j].line.lineNo(), ch: 0}) : cm.getValue().length; - - // splice - if (i < 0) { - i = 0; - } - - widgets.splice(i, 0, ...createWidgets(fromIndex, toIndex, widgets.splice(i, j - i))); - - fromLine = null; - toLine = null; - } - - function *createWidgets(start, end, removed) { - let i = 0; - for (const section of findAppliesTo(start, end)) { - while (removed[i] && removed[i].line.lineNo() < section.pos.line) { - clearWidget(removed[i++]); - } - setupMarkers(section); - if (removed[i] && removed[i].line.lineNo() === section.pos.line) { - // reuse old widget - removed[i].section.applies.forEach(apply => { - apply.type.mark.clear(); - apply.value.mark.clear(); - }); - removed[i].section = section; - const newNode = buildElement(section); - removed[i].node.parentNode.replaceChild(newNode, removed[i].node); - removed[i].node = newNode; - setWidgetStyle(removed[i]); - removed[i].changed(); - yield removed[i]; - i++; - continue; - } - // new widget - const widget = cm.addLineWidget(section.pos.line, buildElement(section), { - coverGutter: true, - noHScroll: true, - above: true - }); - widget.section = section; - setWidgetStyle(widget); - yield widget; - } - removed.slice(i).forEach(clearWidget); - } - - function clearWidget(widget) { - widget.clear(); - widget.section.applies.forEach(clearApply); - } - - function clearApply(apply) { - apply.type.mark.clear(); - apply.value.mark.clear(); - apply.mark.clear(); - } - - function setupMarkers({applies}) { - applies.forEach(setupApplyMarkers); - } - - function setupApplyMarkers(apply) { - apply.type.mark = cm.markText( - cm.posFromIndex(apply.type.start), - cm.posFromIndex(apply.type.end), - {clearWhenEmpty: false} - ); - apply.value.mark = cm.markText( - cm.posFromIndex(apply.value.start), - cm.posFromIndex(apply.value.end), - {clearWhenEmpty: false} - ); - apply.mark = cm.markText( - cm.posFromIndex(apply.start), - cm.posFromIndex(apply.end), - {clearWhenEmpty: false} - ); - } - - function buildElement({applies}) { - const el = $element({className: 'applies-to', appendChild: [ - $element({tag: 'label', appendChild: [ - t('appliesLabel'), - // $element({tag: 'svg'}) - ]}), - $element({ - tag: 'ul', - className: 'applies-to-list', - appendChild: applies.map(makeInputEl) - }) - ]}); - if (!$('li', el)) { - $('ul', el).appendChild($element({ - tag: 'li', - className: 'applies-to-everything', - textContent: t('appliesToEverything') - })); - } - return el; - - function makeInputEl(apply) { - const el = $element({tag: 'li', appendChild: makeInput(apply)}); - el.dataset.type = apply.type.text; - el.addEventListener('change', e => { - if (e.target.classList.contains('applies-type')) { - el.dataset.type = apply.type.text; - } - }); - return el; - } - - function makeInput(apply) { - const typeInput = $element({ - tag: 'select', - className: 'applies-type', - appendChild: APPLIES_TYPE.map(([label, value]) => $element({ - tag: 'option', - value: value, - textContent: label - })), - onchange(e) { - applyChange(apply.type, e.target.value); - } - }); - typeInput.value = apply.type.text; - let timer; - const valueInput = $element({ - tag: 'input', - className: 'applies-value', - value: apply.value.text, - oninput(e) { - clearTimeout(timer); - timer = setTimeout(applyChange, THROTTLE_DELAY, apply.value, e.target.value); - }, - onfocus: updateRegexpTest - }); - const regexpTestButton = $element({ - tag: 'button', - type: 'button', - className: 'applies-to-regexp-test', - textContent: t('styleRegexpTestButton'), - onclick() { - regExpTester.toggle(); - regExpTester.update([apply.value.text]); - } - }); - const removeButton = $element({ - tag: 'button', - type: 'button', - className: 'applies-to-remove', - textContent: t('appliesRemove'), - onclick(e) { - const i = applies.indexOf(apply); - let repl; - let from; - let to; - if (applies.length < 2) { - alert('Can\'t remove last applies-to'); - return; - } - if (i === 0) { - from = apply.mark.find().from; - to = applies[i + 1].mark.find().from; - repl = ''; - } else if (i === applies.length - 1) { - from = applies[i - 1].mark.find().to; - to = apply.mark.find().to; - repl = ''; - } else { - from = applies[i - 1].mark.find().to; - to = applies[i + 1].mark.find().from; - repl = ', '; - } - cm.replaceRange(repl, from, to, 'appliesTo'); - clearApply(apply); - e.target.closest('li').remove(); - applies.splice(i, 1); - } - }); - const addButton = $element({ - tag: 'button', - type: 'button', - className: 'applies-to-add', - textContent: t('appliesAdd'), - onclick(e) { - const i = applies.indexOf(apply); - const pos = apply.mark.find().to; - const text = `, ${apply.type.text}("")`; - cm.replaceRange(text, pos, pos, 'appliesTo'); - const index = cm.indexFromPos(pos); - const newApply = { - type: { - text: apply.type.text - }, - value: { - text: '' - } - }; - newApply.start = index + 2; - newApply.type.start = newApply.start; - newApply.type.end = newApply.type.start + newApply.type.text.length; - newApply.value.start = newApply.type.end + 2; - newApply.value.end = newApply.value.start + newApply.value.text.length; - newApply.end = newApply.value.end + 2; - setupApplyMarkers(newApply); - applies.splice(i + 1, 0, newApply); - const li = e.target.closest('li'); - li.parentNode.insertBefore(makeInputEl(newApply), li.nextSibling); - } - }); - return [typeInput, valueInput, regexpTestButton, removeButton, addButton]; - - function updateRegexpTest() { - if (apply.type.text === 'regexp') { - const re = apply.value.text.trim(); - if (re) { - regExpTester.update([re]); - } else { - regExpTester.update([]); - } - } - } - - function applyChange(input, newText) { - const range = input.mark.find(); - input.mark.clear(); - cm.replaceRange(newText, range.from, range.to, 'appliesTo'); - input.mark = cm.markText( - range.from, - cm.findPosH( - range.from, - newText.length, - 'char' - ), - {clearWhenEmpty: false} - ); - input.text = newText; - - if (input === apply.type) { - const range = apply.mark.find(); - apply.mark.clear(); - apply.mark = cm.markText( - input.mark.find().from, - range.to, - {clearWhenEmpty: false} - ); - } - - updateRegexpTest(); - } - } - } - - function *findAppliesTo(posStart, posEnd) { - const text = cm.getValue(); - const re = /^[\t ]*@-moz-document\s+/mg; - const applyRe = /^(url|url-prefix|domain|regexp)\(((['"])(?:\\\\|\\\n|\\\3|[^\n])*?\3|[^)\n]*)\)[\s,]*/i; - let preIndex = re.lastIndex = posStart; - let match; - let pos = cm.posFromIndex(preIndex); - while ((match = re.exec(text))) { - if (match.index >= posEnd) { - return; - } - pos = cm.findPosH(pos, match.index - preIndex, 'char'); - const applies = []; - let t = text.slice(re.lastIndex); - let m; - let offset = 0; - while ((m = t.match(applyRe))) { - const apply = { - type: { - text: m[1] - }, - value: { - text: normalizeString(m[2]) - } - }; - apply.type.start = re.lastIndex + offset; - apply.type.end = apply.type.start + apply.type.text.length; - apply.value.start = apply.type.end + (apply.value.text === m[2] ? 1 : 2); - apply.value.end = apply.value.start + apply.value.text.length; - apply.start = apply.type.start; - apply.end = apply.value.end + (apply.value.text === m[2] ? 1 : 2); - applies.push(apply); - t = t.slice(m[0].length); - offset += m[0].length; - } - yield {pos, applies}; - preIndex = match.index; - re.lastIndex = text.length - t.length; - } - } - - function normalizeString(s) { - if (/^(['"])[\s\S]*\1$/.test(s)) { - return s.slice(1, -1); - } - return s; - } - } - function initHooks() { // sidebar commands $('#save-button').onclick = save;