diff --git a/CliClient/package-lock.json b/CliClient/package-lock.json index 6d3718096..e7e190088 100644 --- a/CliClient/package-lock.json +++ b/CliClient/package-lock.json @@ -15,9 +15,9 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, "acorn": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", - "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==" + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.2.0.tgz", + "integrity": "sha512-8oe72N3WPMjA+2zVG71Ia0nXZ8DpQH+QyyHO+p06jT8eg8FGG3FbcUIi8KziHlAfheJQZeoqbvq1mQSQHXKYLw==" }, "acorn-globals": { "version": "4.3.2", @@ -26,13 +26,6 @@ "requires": { "acorn": "^6.0.1", "acorn-walk": "^6.0.1" - }, - "dependencies": { - "acorn": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.2.0.tgz", - "integrity": "sha512-8oe72N3WPMjA+2zVG71Ia0nXZ8DpQH+QyyHO+p06jT8eg8FGG3FbcUIi8KziHlAfheJQZeoqbvq1mQSQHXKYLw==" - } } }, "acorn-walk": { @@ -518,18 +511,6 @@ "abab": "^2.0.0", "whatwg-mimetype": "^2.2.0", "whatwg-url": "^7.0.0" - }, - "dependencies": { - "whatwg-url": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.0.0.tgz", - "integrity": "sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ==", - "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - } } }, "debug": { @@ -1472,62 +1453,13 @@ "css": "^2.2.4", "html-entities": "^1.2.1", "jsdom": "^11.9.0" - } - }, - "joplin-turndown-plugin-gfm": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/joplin-turndown-plugin-gfm/-/joplin-turndown-plugin-gfm-1.0.8.tgz", - "integrity": "sha512-uXgq2zGvjiMl/sXG7946EGhh1pyGbZ0L/6z21LBi8D6BJgHQufmXdve/UP3zpgnhiFhfXvzGY10uNaTuDQ99iQ==" - }, - "jpeg-js": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.1.2.tgz", - "integrity": "sha1-E1uZLAV1yYXPoPSUoyJ+0jhYPs4=" - }, - "js-tokens": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", - "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" - }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "optional": true - }, - "jsdom": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz", - "integrity": "sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw==", - "requires": { - "abab": "^2.0.0", - "acorn": "^5.5.3", - "acorn-globals": "^4.1.0", - "array-equal": "^1.0.0", - "cssom": ">= 0.3.2 < 0.4.0", - "cssstyle": "^1.0.0", - "data-urls": "^1.0.0", - "domexception": "^1.0.1", - "escodegen": "^1.9.1", - "html-encoding-sniffer": "^1.0.2", - "left-pad": "^1.3.0", - "nwsapi": "^2.0.7", - "parse5": "4.0.0", - "pn": "^1.1.0", - "request": "^2.87.0", - "request-promise-native": "^1.0.5", - "sax": "^1.2.4", - "symbol-tree": "^3.2.2", - "tough-cookie": "^2.3.4", - "w3c-hr-time": "^1.0.1", - "webidl-conversions": "^4.0.2", - "whatwg-encoding": "^1.0.3", - "whatwg-mimetype": "^2.1.0", - "whatwg-url": "^6.4.1", - "ws": "^5.2.0", - "xml-name-validator": "^3.0.0" }, "dependencies": { + "acorn": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==" + }, "ajv": { "version": "6.10.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.1.tgz", @@ -1581,6 +1513,39 @@ "har-schema": "^2.0.0" } }, + "jsdom": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz", + "integrity": "sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw==", + "requires": { + "abab": "^2.0.0", + "acorn": "^5.5.3", + "acorn-globals": "^4.1.0", + "array-equal": "^1.0.0", + "cssom": ">= 0.3.2 < 0.4.0", + "cssstyle": "^1.0.0", + "data-urls": "^1.0.0", + "domexception": "^1.0.1", + "escodegen": "^1.9.1", + "html-encoding-sniffer": "^1.0.2", + "left-pad": "^1.3.0", + "nwsapi": "^2.0.7", + "parse5": "4.0.0", + "pn": "^1.1.0", + "request": "^2.87.0", + "request-promise-native": "^1.0.5", + "sax": "^1.2.4", + "symbol-tree": "^3.2.2", + "tough-cookie": "^2.3.4", + "w3c-hr-time": "^1.0.1", + "webidl-conversions": "^4.0.2", + "whatwg-encoding": "^1.0.3", + "whatwg-mimetype": "^2.1.0", + "whatwg-url": "^6.4.1", + "ws": "^5.2.0", + "xml-name-validator": "^3.0.0" + } + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -1604,6 +1569,11 @@ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, + "parse5": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", + "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==" + }, "request": { "version": "2.88.0", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", @@ -1651,9 +1621,48 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "whatwg-url": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", + "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "requires": { + "async-limiter": "~1.0.0" + } } } }, + "joplin-turndown-plugin-gfm": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/joplin-turndown-plugin-gfm/-/joplin-turndown-plugin-gfm-1.0.8.tgz", + "integrity": "sha512-uXgq2zGvjiMl/sXG7946EGhh1pyGbZ0L/6z21LBi8D6BJgHQufmXdve/UP3zpgnhiFhfXvzGY10uNaTuDQ99iQ==" + }, + "jpeg-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.1.2.tgz", + "integrity": "sha1-E1uZLAV1yYXPoPSUoyJ+0jhYPs4=" + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", @@ -2199,11 +2208,6 @@ "data-uri-to-buffer": "0.0.3" } }, - "parse5": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", - "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==" - }, "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -3241,9 +3245,9 @@ "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==" }, "whatwg-url": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", - "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.0.0.tgz", + "integrity": "sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ==", "requires": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", @@ -3296,14 +3300,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, - "ws": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", - "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", - "requires": { - "async-limiter": "~1.0.0" - } - }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/CliClient/tests/htmlUtils.js b/CliClient/tests/htmlUtils.js new file mode 100644 index 000000000..10be8dd62 --- /dev/null +++ b/CliClient/tests/htmlUtils.js @@ -0,0 +1,105 @@ +require('app-module-path').addPath(__dirname); + +const { time } = require('lib/time-utils.js'); +const { fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js'); +const htmlUtils = require('lib/htmlUtils.js'); + +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); + +describe('htmlUtils', function() { + + beforeEach(async (done) => { + done(); + }); + + it('should extract image URLs', async (done) => { + const testCases = [ + ['', ['http://test.com/img.png']], + [' ', ['http://test.com/img.png', 'http://test.com/img2.png']], + ['testing', ['http://test.com/img.png']], + ['nothing here', []], + ['', []], + ]; + + for (let i = 0; i < testCases.length; i++) { + const md = testCases[i][0]; + const expected = testCases[i][1]; + + expect(htmlUtils.extractImageUrls(md).join(' ')).toBe(expected.join(' ')); + } + + done(); + }); + + it('should replace image URLs', async (done) => { + const testCases = [ + ['', ['http://other.com/img2.png'], ''], + [' ', ['http://other.com/img2.png', 'http://other.com/img3.png'], ' '], + ['testing', ['http://other.com/img.png'], 'testing'], + ]; + + const callback = (urls) => { + let i = -1; + + return function(src) { + i++; + return urls[i]; + } + } + + for (let i = 0; i < testCases.length; i++) { + const md = testCases[i][0]; + const r = htmlUtils.replaceImageUrls(md, callback(testCases[i][1])); + expect(r.trim()).toBe(testCases[i][2].trim()); + } + + done(); + }); + + it('should encode attributes', async (done) => { + const testCases = [ + [{ a: 'one', b: 'two' }, 'a="one" b="two"'], + [{ a: 'one&two' }, 'a="one&two"'], + ]; + + for (let i = 0; i < testCases.length; i++) { + const attrs = testCases[i][0]; + const expected = testCases[i][1]; + expect(htmlUtils.attributesHtml(attrs)).toBe(expected); + } + + done(); + }); + + it('should prepend a base URL', async (done) => { + const testCases = [ + [ + 'Something', + 'http://test.com', + 'Something', + ], + [ + 'a b', + 'http://test.com', + 'a b', + ], + [ + 'a b', + 'http://test.com', + 'a b', + ], + ]; + + for (let i = 0; i < testCases.length; i++) { + const html = testCases[i][0]; + const baseUrl = testCases[i][1]; + const expected = testCases[i][2]; + expect(htmlUtils.prependBaseUrl(html, baseUrl)).toBe(expected); + } + + done(); + }); + +}); \ No newline at end of file diff --git a/Clipper/joplin-webclipper/content_scripts/index.js b/Clipper/joplin-webclipper/content_scripts/index.js index 5b5324802..b8a80e6e1 100644 --- a/Clipper/joplin-webclipper/content_scripts/index.js +++ b/Clipper/joplin-webclipper/content_scripts/index.js @@ -61,13 +61,19 @@ const output = {}; for (let i = 0; i < images.length; i++) { const img = images[i]; - const src = forceAbsoluteUrls ? absoluteUrl(img.src) : img.src; - output[src] = { + if (img.classList && img.classList.contains('joplin-clipper-hidden')) continue; + + let src = imageSrc(img); + src = forceAbsoluteUrls ? absoluteUrl(src) : src; + + if (!output[src]) output[src] = []; + + output[src].push({ width: img.width, height: img.height, naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, - }; + }); } return output; } @@ -86,20 +92,29 @@ return output; } + // In general we should use currentSrc because that's the image that's currently displayed, + // especially within tags or with srcset. In these cases there can be multiple + // sources and the best one is probably the one being displayed, thus currentSrc. + function imageSrc(image) { + if (image.currentSrc) return image.currentSrc; + return image.src; + } + // Cleans up element by removing all its invisible children (which we don't want to render as Markdown) // And hard-code the image dimensions so that the information can be used by the clipper server to // display them at the right sizes in the notes. - function cleanUpElement(element, imageSizes) { + function cleanUpElement(convertToMarkup, element, imageSizes, imageIndexes) { const childNodes = element.childNodes; + const hiddenNodes = []; - for (let i = childNodes.length - 1; i >= 0; i--) { + for (let i = 0; i < childNodes.length; i++) { const node = childNodes[i]; const nodeName = node.nodeName.toLowerCase(); const isHidden = node && node.classList && node.classList.contains('joplin-clipper-hidden'); if (isHidden) { - element.removeChild(node); + hiddenNodes.push(node); } else { // If the data-joplin-clipper-value has been set earlier, create a new DIV element @@ -112,17 +127,25 @@ } if (nodeName === 'img') { - node.src = absoluteUrl(node.src); - const imageSize = imageSizes[node.src]; - if (imageSize) { + const src = absoluteUrl(imageSrc(node)); + node.setAttribute('src', src); + if (!(src in imageIndexes)) imageIndexes[src] = 0; + const imageSize = imageSizes[src][imageIndexes[src]]; + imageIndexes[src]++; + if (imageSize && convertToMarkup === 'markdown') { node.width = imageSize.width; node.height = imageSize.height; } } - cleanUpElement(node, imageSizes); + cleanUpElement(convertToMarkup, node, imageSizes, imageIndexes); } } + + for (const hiddenNode of hiddenNodes) { + if (!hiddenNode.parentNode) continue; + hiddenNode.parentNode.removeChild(hiddenNode); + } } // When we clone the document before cleaning it, we lose some of the information that might have been set via CSS or @@ -132,9 +155,11 @@ function preProcessDocument(element) { const childNodes = element.childNodes; - for (let i = 0; i < childNodes.length; i++) { + for (let i = childNodes.length - 1; i >= 0; i--) { const node = childNodes[i]; const nodeName = node.nodeName.toLowerCase(); + const nodeParent = node.parentNode; + const nodeParentName = nodeParent ? nodeParent.nodeName.toLowerCase() : ''; let isVisible = node.nodeType === 1 ? window.getComputedStyle(node).display !== 'none' : true; if (isVisible && ['script', 'noscript', 'style', 'select', 'option', 'button'].indexOf(nodeName) >= 0) isVisible = false; @@ -153,7 +178,13 @@ if (a && a.toLowerCase().indexOf('math/tex') >= 0) isVisible = true; } - if (!isVisible) { + if (nodeName === 'source' && nodeParentName === 'picture') { + isVisible = false + } + + if (node.nodeType === 8) { // Comments are just removed since we can't add a class + node.parentNode.removeChild(node); + } else if (!isVisible) { node.classList.add('joplin-clipper-hidden'); } else { preProcessDocument(node); @@ -179,19 +210,25 @@ // Given a document, return a '; + return output; } function documentForReadability() { @@ -223,7 +260,9 @@ async function prepareCommandResponse(command) { console.info('Got command: ' + command.name); - const clippedContentResponse = (title, html, imageSizes, anchorNames) => { + const convertToMarkup = command.preProcessFor ? command.preProcessFor : 'markdown'; + + const clippedContentResponse = (title, html, imageSizes, anchorNames, stylesheets) => { return { name: 'clippedContent', title: title, @@ -234,6 +273,9 @@ tags: command.tags || '', image_sizes: imageSizes, anchor_names: anchorNames, + source_command: Object.assign({}, command), + convert_to: convertToMarkup, + stylesheets: stylesheets, }; } @@ -255,7 +297,6 @@ } else if (command.name === "isProbablyReaderable") { const ok = isProbablyReaderable(documentForReadability()); - console.info('isProbablyReaderable', ok); return { name: 'isProbablyReaderable', value: ok }; } else if (command.name === "completePageHtml") { @@ -266,8 +307,11 @@ // directly on the document, so we make a copy of it first. const cleanDocument = document.body.cloneNode(true); const imageSizes = getImageSizes(document, true); - cleanUpElement(cleanDocument, imageSizes); - return clippedContentResponse(pageTitle(), cleanDocument.innerHTML, imageSizes, getAnchorNames(document)); + const imageIndexes = {}; + cleanUpElement(convertToMarkup, cleanDocument, imageSizes, imageIndexes); + + const stylesheets = convertToMarkup === 'html' ? getStyleSheets(document) : null; + return clippedContentResponse(pageTitle(), cleanDocument.innerHTML, imageSizes, getAnchorNames(document), stylesheets); } else if (command.name === "selectedHtml") { @@ -277,7 +321,8 @@ const container = document.createElement('div'); container.appendChild(range.cloneContents()); const imageSizes = getImageSizes(document, true); - cleanUpElement(container, imageSizes); + const imageIndexes = {}; + cleanUpElement(convertToMarkup, container, imageSizes, imageIndexes); return clippedContentResponse(pageTitle(), container.innerHTML, getImageSizes(document), getAnchorNames(document)); } else if (command.name === 'screenshot') { diff --git a/Clipper/joplin-webclipper/popup/src/App.js b/Clipper/joplin-webclipper/popup/src/App.js index 109bf8ace..fb2ef624b 100644 --- a/Clipper/joplin-webclipper/popup/src/App.js +++ b/Clipper/joplin-webclipper/popup/src/App.js @@ -7,6 +7,20 @@ import led_orange from './led_orange.png'; const { connect } = require('react-redux'); const { bridge } = require('./bridge'); +function commandUserString(command) { + const s = []; + + if (command.name === 'simplifiedPageHtml') s.push('Simplified page'); + if (command.name === 'completePageHtml') s.push('Complete page'); + if (command.name === 'selectedHtml') s.push('Selection'); + if (command.name === 'pageUrl') s.push('URL only'); + + const p = command.preProcessFor ? command.preProcessFor : 'markdown'; + s.push('(' + p + ')'); + + return s.join(' '); +} + class PreviewComponent extends React.PureComponent { constructor() { @@ -17,7 +31,7 @@ class PreviewComponent extends React.PureComponent { componentDidMount() { if (!this.bodyRef.current) return; - + // Because the text size is made twice smaller with CSS, we need // to also reduce the size of the images const imgs = this.bodyRef.current.getElementsByTagName('img'); @@ -32,6 +46,7 @@ class PreviewComponent extends React.PureComponent {

Title:

+

Type: {commandUserString(this.props.command)}

Confirm
); @@ -84,6 +99,14 @@ class AppComponent extends Component { this.clipComplete_click = () => { bridge().sendCommandToActiveTab({ name: 'completePageHtml', + preProcessFor: 'markdown', + }); + } + + this.clipCompleteHtml_click = () => { + bridge().sendCommandToActiveTab({ + name: 'completePageHtml', + preProcessFor: 'html', }); } @@ -259,6 +282,7 @@ class AppComponent extends Component { title={content.title} body_html={content.body_html} onTitleChange={this.contentTitle_change} + command={content.source_command} /> } @@ -360,6 +384,7 @@ class AppComponent extends Component {