├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .node-version ├── .prettierrc ├── Material.sketch ├── README.md ├── package.json ├── renovate.json ├── src ├── icons │ ├── icon128.png │ ├── icon16.png │ └── icon48.png ├── manifest.json └── scripts │ ├── components │ ├── AltEditor │ │ ├── AltEditor.tsx │ │ └── index.ts │ └── App │ │ ├── App.tsx │ │ └── index.ts │ └── main.tsx ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.config.js 2 | dist/ 3 | src/icons 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:prettier/recommended', 4 | 'plugin:@typescript-eslint/eslint-recommended', 5 | 'plugin:react/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | env: { node: true, browser: true, es6: true }, 9 | plugins: ['@typescript-eslint', 'react'], 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | settings: { 18 | react: { 19 | version: 'detect', 20 | }, 21 | }, 22 | rules: { 23 | '@typescript-eslint/explicit-function-return-type': 'off', 24 | '@typescript-eslint/no-unused-vars': [2, { args: 'none', ignoreRestSiblings: true }], 25 | 'react/prop-types': 'off', 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: setup node 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: '14.x' 19 | - name: run lint 20 | run: | 21 | yarn install --frozen-lockfile --check-files 22 | yarn lint:eslint 23 | yarn lint:prettier 24 | yarn typecheck 25 | env: 26 | CI: true 27 | - name: build 28 | run: yarn build:prod 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | publish/ 4 | Noalte.zip 5 | .DS_Store 6 | .vscode -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 14.16.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": false, 4 | "tabWidth": 2, 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "trailingComma": "all", 8 | "arrowParens": "always", 9 | "jsxBracketSameLine": true, 10 | "printWidth": 120 11 | } 12 | -------------------------------------------------------------------------------- /Material.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masuP9/Noalte/35147e5c05de49df52f6f46d5b26fa43eecf7318/Material.sketch -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Noalte 2 | 3 | alt text editor for note 4 | 5 | ![Noalteの動作イメージ。挿入画像の隣に代替テキスト入力欄が表示される](https://lh3.googleusercontent.com/DHHPS8Esex_EpDIig2NGt9Odosx2czPcAYOnMT_LJsmGdTx1UYhuepm9AdaTbRmqmrVbCyndtAVemzBDV9QrisHeLcw=w640-h400-e365-rj-sc0x00ffffff) 6 | 7 | ## Download 8 | 9 | [Noalte \- Chrome ウェブストア](https://chrome.google.com/webstore/detail/noalte/fllgnabhoilflhpebloipnkjelkfcjbi) 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Noalte", 3 | "version": "0.3.0", 4 | "author": "Soichi Masuda (https://github.com/masuP9/)", 5 | "license": "MIT", 6 | "bugs": "https://github.com/masuP9/noalte/issues", 7 | "devDependencies": { 8 | "@types/node": "14.14.37", 9 | "@types/react": "17.0.3", 10 | "@types/react-dom": "17.0.3", 11 | "@types/styled-components": "5.1.9", 12 | "@typescript-eslint/eslint-plugin": "4.20.0", 13 | "@typescript-eslint/parser": "4.20.0", 14 | "babel-cli": "6.26.0", 15 | "babel-preset-react-app": "10.0.0", 16 | "copy-webpack-plugin": "8.1.0", 17 | "eslint": "7.23.0", 18 | "eslint-config-prettier": "8.1.0", 19 | "eslint-plugin-prettier": "3.3.1", 20 | "eslint-plugin-react": "7.23.1", 21 | "eslint-plugin-react-hooks": "4.2.0", 22 | "prettier": "2.2.1", 23 | "source-map-loader": "2.0.1", 24 | "ts-loader": "8.1.0", 25 | "typescript": "4.2.3", 26 | "webpack": "5.30.0", 27 | "webpack-cli": "4.6.0" 28 | }, 29 | "dependencies": { 30 | "crx-hotreload": "1.0.6", 31 | "react": "17.0.2", 32 | "react-dom": "17.0.2", 33 | "styled-components": "5.2.3" 34 | }, 35 | "scripts": { 36 | "build:dev": "webpack --mode development", 37 | "build:prod": "webpack --mode production", 38 | "prod": "yarn build:prod && zip -r Noalte.zip publish", 39 | "dev": "yarn build:dev -w", 40 | "lint": "yarn lint:eslint && yarn lint:prettier", 41 | "lint:eslint": "eslint \"src/**/*.+(ts|tsx)\"", 42 | "lint:prettier": "prettier --check 'src/**/*.+(ts|tsx|js)'", 43 | "format:eslint": "yarn lint --fix", 44 | "format:prettier": "prettier --write 'src/**/*.+(ts|tsx|js)'", 45 | "typecheck": "tsc -p . --noEmit" 46 | }, 47 | "resolutions": { 48 | "@types/react": "17.0.3", 49 | "@types/react-dom": "17.0.3", 50 | "kind-of": "^6.0.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masuP9/Noalte/35147e5c05de49df52f6f46d5b26fa43eecf7318/src/icons/icon128.png -------------------------------------------------------------------------------- /src/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masuP9/Noalte/35147e5c05de49df52f6f46d5b26fa43eecf7318/src/icons/icon16.png -------------------------------------------------------------------------------- /src/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masuP9/Noalte/35147e5c05de49df52f6f46d5b26fa43eecf7318/src/icons/icon48.png -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Noalte", 4 | "description": "alt text editor for note", 5 | "version": "0.3.0", 6 | "icons": { 7 | "16": "icon16.png", 8 | "48": "icon48.png", 9 | "128": "icon128.png" 10 | }, 11 | "content_scripts": [ 12 | { 13 | "matches": [ 14 | "https://note.mu/*", 15 | "https://note.com/*" 16 | ], 17 | "js": [ 18 | "content_script.js" 19 | ] 20 | } 21 | ], 22 | "background": { "scripts": ["hot-reload.js"] } 23 | } 24 | -------------------------------------------------------------------------------- /src/scripts/components/AltEditor/AltEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useMemo } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export type Position = { 5 | left: number; 6 | top: number; 7 | }; 8 | 9 | type Props = React.FormHTMLAttributes & { 10 | selectedImage: HTMLImageElement; 11 | onClose: () => void; 12 | }; 13 | 14 | const MARGIN_BETWEEN_FORM_AND_IMAGE = 12; 15 | 16 | const getPositionFromImage = (image: HTMLImageElement): Position => { 17 | const rect = image.getBoundingClientRect(); 18 | 19 | return { 20 | left: rect.right + MARGIN_BETWEEN_FORM_AND_IMAGE, 21 | top: window.pageYOffset + rect.top, 22 | }; 23 | }; 24 | 25 | export const AltEditor: React.VFC = ({ selectedImage, onClose, ...rest }) => { 26 | const [value, setValue] = useState(''); 27 | const [position, setPosition] = useState({ left: 0, top: 0 }); 28 | const [altUpdateSuccess, setAltUpdateSuccess] = useState(false); 29 | 30 | const imageSizeObserver = useMemo( 31 | () => 32 | new ResizeObserver((records) => { 33 | records.forEach((entry) => { 34 | const { target } = entry; 35 | if (target instanceof HTMLImageElement) { 36 | setPosition(getPositionFromImage(target)); 37 | } 38 | }); 39 | }), 40 | [], 41 | ); 42 | 43 | useEffect(() => { 44 | setValue(selectedImage.alt); 45 | setPosition(getPositionFromImage(selectedImage)); 46 | imageSizeObserver.observe(selectedImage); 47 | 48 | return () => { 49 | imageSizeObserver.disconnect(); 50 | }; 51 | }, [selectedImage, imageSizeObserver]); 52 | 53 | const bodySizeObserver = useMemo( 54 | () => 55 | new ResizeObserver((entries) => 56 | entries.forEach((entry) => { 57 | setPosition(getPositionFromImage(selectedImage)); 58 | }), 59 | ), 60 | [selectedImage], 61 | ); 62 | 63 | useEffect(() => { 64 | bodySizeObserver.observe(document.body); 65 | 66 | return () => { 67 | bodySizeObserver.disconnect(); 68 | }; 69 | }, [bodySizeObserver]); 70 | 71 | const handleSubmit = (e: React.FormEvent) => { 72 | e.preventDefault(); 73 | selectedImage.setAttribute('alt', value); 74 | setAltUpdateSuccess(true); 75 | }; 76 | 77 | const handleChangeInputValue = (e: React.ChangeEvent) => { 78 | setValue(e.target.value); 79 | setAltUpdateSuccess(false); 80 | }; 81 | 82 | const handleClickCloseButton = (e: React.MouseEvent) => { 83 | onClose(); 84 | }; 85 | 86 | return ( 87 |
88 | 94 | 95 | 96 | {altUpdateSuccess ? '✅ 保存しました' : null} 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 |
105 | ); 106 | }; 107 | 108 | type FormProps = { 109 | readonly position: Position; 110 | }; 111 | 112 | const Form = styled.form` 113 | position: absolute !important; 114 | z-index: 1 !important; 115 | top: ${({ position }) => `${position.top}px`} !important; 116 | left: ${({ position }) => `${position.left}px`} !important; 117 | padding: 1em !important; 118 | border-radius: 4px !important; 119 | background-color: #efefef !important; 120 | 121 | & :focus { 122 | outline: none !important; 123 | box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.75) !important; 124 | } 125 | `; 126 | 127 | const Label = styled.label` 128 | margin-bottom: 8px !important; 129 | overflow: visible !important; 130 | 131 | & > span { 132 | display: block; 133 | margin-bottom: 4px !important; 134 | } 135 | `; 136 | 137 | const Input = styled.input` 138 | margin-bottom: 8px !important; 139 | padding: 4px 8px !important; 140 | border: 1px solid #757575 !important; 141 | border-radius: 4px !important; 142 | background-color: white !important; 143 | `; 144 | 145 | const SubmitModule = styled.div` 146 | display: flex !important; 147 | align-items: center !important; 148 | 149 | & > button { 150 | all: initial; 151 | background-color: #157660; 152 | padding: 4px 8px; 153 | border-radius: 4px; 154 | font-weight: bold; 155 | color: white; 156 | } 157 | `; 158 | 159 | const Message = styled.p` 160 | margin-left: 8px !important; 161 | `; 162 | 163 | const CloseButton = styled.button` 164 | display: flex !important; 165 | align-items: center !important; 166 | justify-content: center !important; 167 | position: absolute !important; 168 | top: 0 !important; 169 | right: 0 !important; 170 | transform: translate(16px, -16px) !important; 171 | width: 32px; 172 | height: 32px; 173 | border-radius: 50% !important; 174 | border: 1px solid #555 !important; 175 | background-color: white !important; 176 | font-weight: bold !important; 177 | `; 178 | -------------------------------------------------------------------------------- /src/scripts/components/AltEditor/index.ts: -------------------------------------------------------------------------------- 1 | export { AltEditor } from './AltEditor'; 2 | -------------------------------------------------------------------------------- /src/scripts/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useMemo } from 'react'; 2 | import { AltEditor } from '../AltEditor'; 3 | 4 | const filterFirstChildImagesFromNodes = (nodeList: NodeList): HTMLImageElement[] => { 5 | const childNodes = Array.from(nodeList).flatMap((node) => (node.firstChild != null ? [node.firstChild] : [])); 6 | const images = childNodes.filter((node): node is HTMLImageElement => node instanceof HTMLImageElement); 7 | return images; 8 | }; 9 | 10 | export const App: React.VFC = () => { 11 | const [selectedImage, setSelectedImage] = useState(null); 12 | const [observingRoot, setObservingRoot] = useState(false); 13 | 14 | const handleClickAddedImage = (e: Event) => { 15 | if (e.target instanceof HTMLImageElement) { 16 | setSelectedImage(e.target); 17 | } 18 | }; 19 | 20 | const editorObserverOption = useMemo( 21 | () => ({ 22 | childList: true, 23 | }), 24 | [], 25 | ); 26 | 27 | const editorObserver = useMemo( 28 | () => 29 | new MutationObserver((records) => { 30 | records.forEach((record) => { 31 | const { addedNodes, removedNodes } = record; 32 | if (addedNodes.length > 0) { 33 | const addImages = filterFirstChildImagesFromNodes(addedNodes); 34 | 35 | if (addImages.length > 0) { 36 | addImages.forEach((image) => { 37 | image.addEventListener('click', handleClickAddedImage); 38 | }); 39 | setSelectedImage(null); 40 | } 41 | } 42 | 43 | if (removedNodes.length > 0) { 44 | const removedImages = filterFirstChildImagesFromNodes(removedNodes); 45 | 46 | if (removedImages.length > 0) { 47 | removedImages.forEach((image) => { 48 | image.removeEventListener('click', handleClickAddedImage); 49 | }); 50 | setSelectedImage(null); 51 | } 52 | } 53 | }); 54 | }), 55 | [], 56 | ); 57 | 58 | const rootObserverOption = useMemo( 59 | () => ({ 60 | childList: true, 61 | }), 62 | [], 63 | ); 64 | 65 | const rootObserver = useMemo( 66 | () => 67 | new MutationObserver((recodes) => { 68 | recodes.forEach((_recode) => { 69 | const editorElement = document.getElementById('note-body'); 70 | 71 | if (editorElement != undefined && !observingRoot) { 72 | const existingImages = editorElement.querySelectorAll('img'); 73 | 74 | if (existingImages.length > 0) { 75 | existingImages.forEach((img) => { 76 | img.addEventListener('click', handleClickAddedImage); 77 | }); 78 | } 79 | 80 | editorObserver.observe(editorElement, editorObserverOption); 81 | setObservingRoot(true); 82 | } else if (editorElement == null && observingRoot) { 83 | editorObserver.disconnect(); 84 | setObservingRoot(false); 85 | } 86 | }); 87 | }), 88 | [editorObserver, editorObserverOption, observingRoot], 89 | ); 90 | 91 | useEffect(() => { 92 | const editorElement = document.getElementById('note-body'); 93 | 94 | if (!observingRoot) { 95 | rootObserver.observe(document.body, rootObserverOption); 96 | setObservingRoot(true); 97 | } 98 | 99 | if (editorElement !== null) { 100 | editorObserver.observe(editorElement, editorObserverOption); 101 | 102 | const existingImages = editorElement.querySelectorAll('img'); 103 | 104 | if (existingImages.length > 0) { 105 | existingImages.forEach((img) => { 106 | img.addEventListener('click', handleClickAddedImage); 107 | }); 108 | } 109 | } 110 | }, [editorObserver, editorObserverOption, rootObserver, rootObserverOption, observingRoot]); 111 | 112 | if (selectedImage === null) { 113 | return
; 114 | } 115 | 116 | return ( 117 | { 120 | setSelectedImage(null); 121 | }} 122 | /> 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/scripts/components/App/index.ts: -------------------------------------------------------------------------------- 1 | export { App } from './App'; 2 | -------------------------------------------------------------------------------- /src/scripts/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDom from 'react-dom'; 3 | import { App } from './components/App'; 4 | 5 | function initializeApp(): void { 6 | const rootApp = document.createElement('div'); 7 | rootApp.setAttribute('id', 'Noalte-alt-editor-for-note'); 8 | document.body.appendChild(rootApp); 9 | ReactDom.render(, rootApp); 10 | } 11 | 12 | window.onload = (_e: Event): void => { 13 | initializeApp(); 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "sourceMap": true, 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "jsx": "react", 10 | "types": ["react", "node"] 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "**/*.spec.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyPlugin = require('copy-webpack-plugin'); 3 | 4 | module.exports = (env, argv) => { 5 | const IS_PRODUCTION = argv.mode === 'production'; 6 | 7 | return { 8 | devtool: 'source-map', 9 | entry: './src/scripts/main.tsx', 10 | output: { filename: 'content_script.js', path: path.resolve(__dirname, IS_PRODUCTION ? 'publish' : 'dist') }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.ts(x?)$/, 15 | exclude: /node_modules/, 16 | use: 'ts-loader', 17 | }, 18 | { 19 | enforce: 'pre', 20 | test: /\.js$/, 21 | loader: 'source-map-loader', 22 | }, 23 | ], 24 | }, 25 | resolve: { 26 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 27 | }, 28 | plugins: [ 29 | new CopyPlugin({ 30 | patterns: [ 31 | { from: 'src/manifest.json' }, 32 | { from: 'src/icons' }, 33 | { from: 'node_modules/crx-hotreload/hot-reload.js' }, 34 | ], 35 | }), 36 | ], 37 | }; 38 | }; 39 | --------------------------------------------------------------------------------