initial commit
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
93
README.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Jira Feedback Chrome Extension
|
||||||
|
|
||||||
|
A Chrome extension that allows users to capture and submit feedback directly to Jira with screenshots, screen recordings, and annotations.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Authentication with Google and Jira
|
||||||
|
- Screenshot capture with annotation tools
|
||||||
|
- Screen recording with audio
|
||||||
|
- Text-based feedback submission
|
||||||
|
- Direct integration with Jira
|
||||||
|
- Context information collection
|
||||||
|
- Multiple feedback templates
|
||||||
|
- Customizable settings
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create a Google OAuth 2.0 client ID:
|
||||||
|
- Go to the [Google Cloud Console](https://console.cloud.google.com)
|
||||||
|
- Create a new project or select an existing one
|
||||||
|
- Enable the Google OAuth2 API
|
||||||
|
- Create credentials (OAuth 2.0 Client ID)
|
||||||
|
- Add the client ID to manifest.json
|
||||||
|
|
||||||
|
4. Configure Jira OAuth 2.0:
|
||||||
|
- Go to your Jira instance settings
|
||||||
|
- Create a new OAuth 2.0 integration
|
||||||
|
- Add the client ID and secret to the extension settings
|
||||||
|
|
||||||
|
5. Build the extension:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Load the extension in Chrome:
|
||||||
|
- Open Chrome and go to `chrome://extensions`
|
||||||
|
- Enable "Developer mode"
|
||||||
|
- Click "Load unpacked"
|
||||||
|
- Select the `dist` directory
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Click the extension icon in the toolbar or use the keyboard shortcut (Ctrl+Shift+F / Cmd+Shift+F)
|
||||||
|
2. Login with your Google and Jira accounts
|
||||||
|
3. Choose a feedback type:
|
||||||
|
- Screenshot
|
||||||
|
- Screen recording
|
||||||
|
- Text-based feedback
|
||||||
|
4. Add annotations or comments
|
||||||
|
5. Fill in the feedback details
|
||||||
|
6. Submit to Jira
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Run in development mode:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Run tests:
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- Lint code:
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- OAuth 2.0 implementation for secure authentication
|
||||||
|
- Secure storage of credentials using Chrome's storage API
|
||||||
|
- HTTPS-only API communication
|
||||||
|
- Minimal permission requirements
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Commit your changes
|
||||||
|
4. Push to the branch
|
||||||
|
5. Create a Pull Request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details
|
156
annotation.html
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Screenshot Annotation</title>
|
||||||
|
<link rel="stylesheet" href="styles/annotator.css">
|
||||||
|
<style>
|
||||||
|
body, html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#annotation-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding: 10px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn, .action-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover, .action-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #2196f3;
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-group input[type="color"] {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-group input[type="range"] {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="annotation-container">
|
||||||
|
<div id="toolbar">
|
||||||
|
<button class="tool-btn" data-tool="brush" title="Draw">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="text" title="Add Text">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 7v2h16V7H4z M4 11v2h16v-2H4z M4 15v2h16v-2H4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="arrow" title="Add Arrow">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M5 12h14 M19 12l-4-4 M19 12l-4 4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="rectangle" title="Add Rectangle">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="tool-options">
|
||||||
|
<input type="color" id="color-picker" value="#FF0000">
|
||||||
|
<input type="range" id="stroke-width" min="1" max="20" value="3">
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="done-btn" class="action-btn">Done</button>
|
||||||
|
<button id="cancel-btn" class="action-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div id="canvas-container">
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="js/annotation.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
assets/icon.png
Normal file
After Width: | Height: | Size: 731 B |
5
assets/icons/icon.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="14" y="14" width="100" height="100" rx="20" fill="#0052CC"/>
|
||||||
|
<path d="M44 64 L60 80 L84 48" stroke="white" stroke-width="12" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 333 B |
BIN
assets/icons/icon128-disabled.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/icons/icon128.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/icons/icon16-disabled.png
Normal file
After Width: | Height: | Size: 286 B |
BIN
assets/icons/icon16.png
Normal file
After Width: | Height: | Size: 301 B |
BIN
assets/icons/icon48-disabled.png
Normal file
After Width: | Height: | Size: 556 B |
BIN
assets/icons/icon48.png
Normal file
After Width: | Height: | Size: 600 B |
4
assets/icons/record.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-12.5v7l5-3.5-5-3.5z" fill="#172B4D"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 307 B |
4
assets/icons/screenshot.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" fill="#172B4D"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 280 B |
4
assets/icons/text.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 17H4v2h10v-2zm6-8H4v2h16V9zM4 15h16v-2H4v2zM4 5v2h16V5H4z" fill="#172B4D"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 221 B |
64
background.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Store the editor window ID
|
||||||
|
let editorWindowId = null;
|
||||||
|
let popupWindowId = null;
|
||||||
|
let screenshotData = null;
|
||||||
|
|
||||||
|
// Listen for extension installation or update
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
console.log('Jira Feedback Extension installed/updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages from popup and editor
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Background script received message:', message);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'editor-ready':
|
||||||
|
// Store editor window ID
|
||||||
|
editorWindowId = sender.tab.id;
|
||||||
|
if (screenshotData) {
|
||||||
|
// Send screenshot data to editor
|
||||||
|
chrome.tabs.sendMessage(editorWindowId, {
|
||||||
|
type: 'init-editor',
|
||||||
|
screenshot: screenshotData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'init-editor':
|
||||||
|
// Store screenshot data
|
||||||
|
screenshotData = message.screenshot;
|
||||||
|
if (editorWindowId) {
|
||||||
|
// Send to editor if it's ready
|
||||||
|
chrome.tabs.sendMessage(editorWindowId, {
|
||||||
|
type: 'init-editor',
|
||||||
|
screenshot: screenshotData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'save-screenshot':
|
||||||
|
// Broadcast edited screenshot to all extension pages
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'screenshot-saved',
|
||||||
|
screenshot: message.screenshot
|
||||||
|
}).catch(error => {
|
||||||
|
console.log('Error broadcasting screenshot:', error);
|
||||||
|
});
|
||||||
|
sendResponse({ success: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('Unknown message type:', message.type);
|
||||||
|
sendResponse({ success: false, error: 'Unknown message type' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling message:', error);
|
||||||
|
sendResponse({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // Keep the message channel open for async response
|
||||||
|
});
|
14
content.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
#jira-feedback-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 999999;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#jira-feedback-overlay.active {
|
||||||
|
display: block;
|
||||||
|
}
|
48
content.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
const html2canvas = require('html2canvas');
|
||||||
|
|
||||||
|
class FeedbackCapture {
|
||||||
|
constructor() {
|
||||||
|
this.initializeOverlay();
|
||||||
|
this.attachMessageListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeOverlay() {
|
||||||
|
this.overlay = document.createElement('div');
|
||||||
|
this.overlay.id = 'jira-feedback-overlay';
|
||||||
|
this.overlay.style.display = 'none';
|
||||||
|
document.body.appendChild(this.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachMessageListener() {
|
||||||
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||||
|
if (request.action === 'captureScreenshot') {
|
||||||
|
this.handleScreenshotCapture(sendResponse);
|
||||||
|
return true; // Keep the message channel open for async response
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleScreenshotCapture(sendResponse) {
|
||||||
|
try {
|
||||||
|
// Create a temporary canvas with the same dimensions as the viewport
|
||||||
|
const canvas = await html2canvas(document.documentElement, {
|
||||||
|
useCORS: true,
|
||||||
|
scale: window.devicePixelRatio || 1,
|
||||||
|
logging: false,
|
||||||
|
allowTaint: true,
|
||||||
|
backgroundColor: null,
|
||||||
|
foreignObjectRendering: true,
|
||||||
|
removeContainer: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenshot = canvas.toDataURL('image/png');
|
||||||
|
sendResponse({ success: true, screenshot });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error capturing screenshot:', error);
|
||||||
|
sendResponse({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the feedback capture functionality
|
||||||
|
new FeedbackCapture();
|
156
dist/annotation.html
vendored
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Screenshot Annotation</title>
|
||||||
|
<link rel="stylesheet" href="styles/annotator.css">
|
||||||
|
<style>
|
||||||
|
body, html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#annotation-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding: 10px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn, .action-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover, .action-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #2196f3;
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-group input[type="color"] {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-group input[type="range"] {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="annotation-container">
|
||||||
|
<div id="toolbar">
|
||||||
|
<button class="tool-btn" data-tool="brush" title="Draw">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="text" title="Add Text">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 7v2h16V7H4z M4 11v2h16v-2H4z M4 15v2h16v-2H4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="arrow" title="Add Arrow">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M5 12h14 M19 12l-4-4 M19 12l-4 4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="rectangle" title="Add Rectangle">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="tool-options">
|
||||||
|
<input type="color" id="color-picker" value="#FF0000">
|
||||||
|
<input type="range" id="stroke-width" min="1" max="20" value="3">
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="done-btn" class="action-btn">Done</button>
|
||||||
|
<button id="cancel-btn" class="action-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div id="canvas-container">
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="js/annotation.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
139
dist/annotation.js
vendored
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/******/ (() => { // webpackBootstrap
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Get screenshot from storage
|
||||||
|
const result = await chrome.storage.local.get(['tempScreenshot']);
|
||||||
|
if (!result.tempScreenshot) {
|
||||||
|
console.error('No screenshot found in storage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create image element
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
// Set canvas size to match image
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
|
||||||
|
// Draw image
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Drawing state
|
||||||
|
let isDrawing = false;
|
||||||
|
let currentTool = 'brush';
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let lastText = null;
|
||||||
|
|
||||||
|
// Tool button setup
|
||||||
|
document.querySelectorAll('.tool-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
// Remove active class from all buttons
|
||||||
|
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
// Add active class to clicked button
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentTool = btn.dataset.tool;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse event handlers
|
||||||
|
canvas.addEventListener('mousedown', startDrawing);
|
||||||
|
canvas.addEventListener('mousemove', draw);
|
||||||
|
canvas.addEventListener('mouseup', stopDrawing);
|
||||||
|
canvas.addEventListener('mouseout', stopDrawing);
|
||||||
|
function startDrawing(e) {
|
||||||
|
isDrawing = true;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
startX = e.clientX - rect.left;
|
||||||
|
startY = e.clientY - rect.top;
|
||||||
|
if (currentTool === 'text') {
|
||||||
|
const text = prompt('Enter text:', '');
|
||||||
|
if (text) {
|
||||||
|
ctx.fillStyle = document.getElementById('color-picker').value;
|
||||||
|
ctx.font = '20px Arial';
|
||||||
|
ctx.fillText(text, startX, startY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function draw(e) {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
ctx.strokeStyle = document.getElementById('color-picker').value;
|
||||||
|
ctx.lineWidth = document.getElementById('stroke-width').value;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
switch (currentTool) {
|
||||||
|
case 'brush':
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(startX, startY);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
startX = x;
|
||||||
|
startY = y;
|
||||||
|
break;
|
||||||
|
case 'rectangle':
|
||||||
|
// Clear previous preview
|
||||||
|
ctx.putImageData(lastImageData, 0, 0);
|
||||||
|
// Draw new rectangle
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(startX, startY, x - startX, y - startY);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'arrow':
|
||||||
|
// Clear previous preview
|
||||||
|
ctx.putImageData(lastImageData, 0, 0);
|
||||||
|
// Draw arrow line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(startX, startY);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
// Draw arrow head
|
||||||
|
const angle = Math.atan2(y - startY, x - startX);
|
||||||
|
const headLength = 20;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x - headLength * Math.cos(angle - Math.PI / 6), y - headLength * Math.sin(angle - Math.PI / 6));
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x - headLength * Math.cos(angle + Math.PI / 6), y - headLength * Math.sin(angle + Math.PI / 6));
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stopDrawing() {
|
||||||
|
isDrawing = false;
|
||||||
|
// Save the current canvas state
|
||||||
|
lastImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save initial canvas state
|
||||||
|
let lastImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Set up done button
|
||||||
|
document.getElementById('done-btn').addEventListener('click', () => {
|
||||||
|
// Convert canvas to data URL
|
||||||
|
const dataUrl = canvas.toDataURL();
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
chrome.storage.local.set({
|
||||||
|
screenshot: dataUrl
|
||||||
|
}, () => {
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up cancel button
|
||||||
|
document.getElementById('cancel-btn').addEventListener('click', () => {
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select brush tool by default
|
||||||
|
document.querySelector('[data-tool="brush"]').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load image
|
||||||
|
img.src = result.tempScreenshot;
|
||||||
|
});
|
||||||
|
/******/ })()
|
||||||
|
;
|
303
dist/annotator.js
vendored
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
/******/ (() => { // webpackBootstrap
|
||||||
|
/******/ "use strict";
|
||||||
|
/******/ // The require scope
|
||||||
|
/******/ var __webpack_require__ = {};
|
||||||
|
/******/
|
||||||
|
/************************************************************************/
|
||||||
|
/******/ /* webpack/runtime/define property getters */
|
||||||
|
/******/ (() => {
|
||||||
|
/******/ // define getter functions for harmony exports
|
||||||
|
/******/ __webpack_require__.d = (exports, definition) => {
|
||||||
|
/******/ for(var key in definition) {
|
||||||
|
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
|
||||||
|
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
|
||||||
|
/******/ }
|
||||||
|
/******/ }
|
||||||
|
/******/ };
|
||||||
|
/******/ })();
|
||||||
|
/******/
|
||||||
|
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||||
|
/******/ (() => {
|
||||||
|
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
|
||||||
|
/******/ })();
|
||||||
|
/******/
|
||||||
|
/************************************************************************/
|
||||||
|
var __webpack_exports__ = {};
|
||||||
|
/* unused harmony export ScreenshotAnnotator */
|
||||||
|
|
||||||
|
class ScreenshotAnnotator {
|
||||||
|
constructor() {
|
||||||
|
this.canvas = null;
|
||||||
|
this.currentTool = 'brush';
|
||||||
|
this.currentColor = '#ff0000';
|
||||||
|
this.currentSize = 5;
|
||||||
|
this.drawingMode = true;
|
||||||
|
}
|
||||||
|
initialize(imageUrl, container) {
|
||||||
|
console.log('Initializing annotator with container:', container);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// Create canvas container
|
||||||
|
const canvasContainer = document.createElement('div');
|
||||||
|
canvasContainer.className = 'canvas-container';
|
||||||
|
|
||||||
|
// Create toolbar
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'annotation-toolbar';
|
||||||
|
toolbar.innerHTML = `
|
||||||
|
<div class="tool-group">
|
||||||
|
<button class="tool-btn active" data-tool="brush" title="Brush">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19H5v-1.92l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="text" title="Text">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="arrow" title="Arrow">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M16.01 11H4v2h12.01v3L20 12l-3.99-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="rectangle" title="Rectangle">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-group">
|
||||||
|
<input type="color" id="color-picker" value="#ff0000" title="Color">
|
||||||
|
</div>
|
||||||
|
<div class="size-group">
|
||||||
|
<input type="range" id="size-slider" min="1" max="20" value="5" title="Size">
|
||||||
|
</div>
|
||||||
|
<div class="action-group">
|
||||||
|
<button class="action-btn" id="undo-btn" title="Undo">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" id="clear-btn" title="Clear">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" id="done-btn" title="Done">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create canvas wrapper for scrolling
|
||||||
|
const canvasWrapper = document.createElement('div');
|
||||||
|
canvasWrapper.className = 'canvas-wrapper';
|
||||||
|
|
||||||
|
// Create canvas
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.margin = 'auto';
|
||||||
|
canvas.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||||
|
|
||||||
|
// Add elements to DOM
|
||||||
|
canvasWrapper.appendChild(canvas);
|
||||||
|
canvasContainer.appendChild(toolbar);
|
||||||
|
canvasContainer.appendChild(canvasWrapper);
|
||||||
|
container.appendChild(canvasContainer);
|
||||||
|
console.log('Creating Fabric canvas');
|
||||||
|
// Initialize Fabric canvas
|
||||||
|
this.canvas = new Canvas(canvas);
|
||||||
|
|
||||||
|
// Load image
|
||||||
|
console.log('Loading image:', imageUrl.substring(0, 100) + '...');
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onerror = error => {
|
||||||
|
console.error('Error loading image:', error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.onload = () => {
|
||||||
|
console.log('Image loaded:', img.width, 'x', img.height);
|
||||||
|
try {
|
||||||
|
// Set canvas size to match window size while maintaining aspect ratio
|
||||||
|
const maxWidth = window.innerWidth - 40;
|
||||||
|
const maxHeight = window.innerHeight - 100;
|
||||||
|
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
|
||||||
|
const width = img.width * scale;
|
||||||
|
const height = img.height * scale;
|
||||||
|
console.log('Setting canvas size:', width, 'x', height);
|
||||||
|
this.canvas.setWidth(width);
|
||||||
|
this.canvas.setHeight(height);
|
||||||
|
|
||||||
|
// Create Fabric image
|
||||||
|
console.log('Creating Fabric image');
|
||||||
|
Image.fromURL(imageUrl, fabricImg => {
|
||||||
|
try {
|
||||||
|
fabricImg.scale(scale);
|
||||||
|
fabricImg.selectable = false;
|
||||||
|
|
||||||
|
// Add image to canvas
|
||||||
|
this.canvas.add(fabricImg);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
this.setupEventListeners(toolbar);
|
||||||
|
console.log('Setup complete');
|
||||||
|
|
||||||
|
// Return promise that resolves when done button is clicked
|
||||||
|
document.getElementById('done-btn').addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
const dataUrl = this.canvas.toDataURL();
|
||||||
|
this.cleanup();
|
||||||
|
resolve(dataUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting data URL:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up Fabric image:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in image onload:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = imageUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in initialize:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupEventListeners(toolbar) {
|
||||||
|
console.log('Setting up event listeners');
|
||||||
|
try {
|
||||||
|
// Tool buttons
|
||||||
|
const toolButtons = toolbar.querySelectorAll('.tool-btn');
|
||||||
|
toolButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
toolButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
this.currentTool = button.dataset.tool;
|
||||||
|
this.canvas.isDrawingMode = this.currentTool === 'brush';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color picker
|
||||||
|
const colorPicker = document.getElementById('color-picker');
|
||||||
|
colorPicker.addEventListener('change', e => {
|
||||||
|
this.currentColor = e.target.value;
|
||||||
|
if (this.canvas.isDrawingMode) {
|
||||||
|
this.canvas.freeDrawingBrush.color = this.currentColor;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Size slider
|
||||||
|
const sizeSlider = document.getElementById('size-slider');
|
||||||
|
sizeSlider.addEventListener('input', e => {
|
||||||
|
this.currentSize = parseInt(e.target.value);
|
||||||
|
if (this.canvas.isDrawingMode) {
|
||||||
|
this.canvas.freeDrawingBrush.width = this.currentSize;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undo button
|
||||||
|
document.getElementById('undo-btn').addEventListener('click', () => {
|
||||||
|
const objects = this.canvas.getObjects();
|
||||||
|
if (objects.length > 1) {
|
||||||
|
// Keep background image
|
||||||
|
this.canvas.remove(objects[objects.length - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear button
|
||||||
|
document.getElementById('clear-btn').addEventListener('click', () => {
|
||||||
|
const objects = this.canvas.getObjects();
|
||||||
|
// Remove all objects except background image
|
||||||
|
for (let i = objects.length - 1; i > 0; i--) {
|
||||||
|
this.canvas.remove(objects[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial brush settings
|
||||||
|
this.canvas.freeDrawingBrush.color = this.currentColor;
|
||||||
|
this.canvas.freeDrawingBrush.width = this.currentSize;
|
||||||
|
|
||||||
|
// Mouse down handler for shapes and text
|
||||||
|
this.canvas.on('mouse:down', options => {
|
||||||
|
if (this.canvas.isDrawingMode) return;
|
||||||
|
const pointer = this.canvas.getPointer(options.e);
|
||||||
|
const startX = pointer.x;
|
||||||
|
const startY = pointer.y;
|
||||||
|
switch (this.currentTool) {
|
||||||
|
case 'rectangle':
|
||||||
|
const rect = new Rect({
|
||||||
|
left: startX,
|
||||||
|
top: startY,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
fill: 'transparent',
|
||||||
|
stroke: this.currentColor,
|
||||||
|
strokeWidth: this.currentSize
|
||||||
|
});
|
||||||
|
this.canvas.add(rect);
|
||||||
|
this.canvas.setActiveObject(rect);
|
||||||
|
break;
|
||||||
|
case 'arrow':
|
||||||
|
const line = new Line([startX, startY, startX, startY], {
|
||||||
|
stroke: this.currentColor,
|
||||||
|
strokeWidth: this.currentSize
|
||||||
|
});
|
||||||
|
this.canvas.add(line);
|
||||||
|
this.canvas.setActiveObject(line);
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
const text = new IText('Type here...', {
|
||||||
|
left: startX,
|
||||||
|
top: startY,
|
||||||
|
fill: this.currentColor,
|
||||||
|
fontSize: this.currentSize * 3
|
||||||
|
});
|
||||||
|
this.canvas.add(text);
|
||||||
|
text.enterEditing();
|
||||||
|
text.selectAll();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse move handler for shapes
|
||||||
|
this.canvas.on('mouse:move', options => {
|
||||||
|
if (!this.canvas.isDrawingMode && this.canvas.getActiveObject()) {
|
||||||
|
const pointer = this.canvas.getPointer(options.e);
|
||||||
|
const activeObj = this.canvas.getActiveObject();
|
||||||
|
if (this.currentTool === 'rectangle') {
|
||||||
|
const width = pointer.x - activeObj.left;
|
||||||
|
const height = pointer.y - activeObj.top;
|
||||||
|
activeObj.set({
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
});
|
||||||
|
} else if (this.currentTool === 'arrow') {
|
||||||
|
const points = [activeObj.x1, activeObj.y1, pointer.x, pointer.y];
|
||||||
|
activeObj.set({
|
||||||
|
x2: pointer.x,
|
||||||
|
y2: pointer.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up handler
|
||||||
|
this.canvas.on('mouse:up', () => {
|
||||||
|
this.canvas.setActiveObject(null);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in setupEventListeners:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup() {
|
||||||
|
try {
|
||||||
|
// Remove event listeners and clean up resources
|
||||||
|
if (this.canvas) {
|
||||||
|
this.canvas.dispose();
|
||||||
|
this.canvas = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in cleanup:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/******/ })()
|
||||||
|
;
|
BIN
dist/assets/icon.png
vendored
Normal file
After Width: | Height: | Size: 731 B |
5
dist/assets/icons/icon.svg
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="14" y="14" width="100" height="100" rx="20" fill="#0052CC"/>
|
||||||
|
<path d="M44 64 L60 80 L84 48" stroke="white" stroke-width="12" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 333 B |
BIN
dist/assets/icons/icon128-disabled.png
vendored
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
dist/assets/icons/icon128.png
vendored
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
dist/assets/icons/icon16-disabled.png
vendored
Normal file
After Width: | Height: | Size: 286 B |
BIN
dist/assets/icons/icon16.png
vendored
Normal file
After Width: | Height: | Size: 301 B |
BIN
dist/assets/icons/icon48-disabled.png
vendored
Normal file
After Width: | Height: | Size: 556 B |
BIN
dist/assets/icons/icon48.png
vendored
Normal file
After Width: | Height: | Size: 600 B |
4
dist/assets/icons/record.svg
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-12.5v7l5-3.5-5-3.5z" fill="#172B4D"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 307 B |
4
dist/assets/icons/screenshot.svg
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" fill="#172B4D"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 280 B |
4
dist/assets/icons/text.svg
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 17H4v2h10v-2zm6-8H4v2h16V9zM4 15h16v-2H4v2zM4 5v2h16V5H4z" fill="#172B4D"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 221 B |
74
dist/background.js
vendored
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/******/ (() => { // webpackBootstrap
|
||||||
|
// Store the editor window ID
|
||||||
|
let editorWindowId = null;
|
||||||
|
let popupWindowId = null;
|
||||||
|
let screenshotData = null;
|
||||||
|
|
||||||
|
// Listen for extension installation or update
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
console.log('Jira Feedback Extension installed/updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages from popup and editor
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Background script received message:', message);
|
||||||
|
try {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'editor-ready':
|
||||||
|
// Store editor window ID
|
||||||
|
editorWindowId = sender.tab.id;
|
||||||
|
if (screenshotData) {
|
||||||
|
// Send screenshot data to editor
|
||||||
|
chrome.tabs.sendMessage(editorWindowId, {
|
||||||
|
type: 'init-editor',
|
||||||
|
screenshot: screenshotData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendResponse({
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'init-editor':
|
||||||
|
// Store screenshot data
|
||||||
|
screenshotData = message.screenshot;
|
||||||
|
if (editorWindowId) {
|
||||||
|
// Send to editor if it's ready
|
||||||
|
chrome.tabs.sendMessage(editorWindowId, {
|
||||||
|
type: 'init-editor',
|
||||||
|
screenshot: screenshotData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendResponse({
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'save-screenshot':
|
||||||
|
// Broadcast edited screenshot to all extension pages
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'screenshot-saved',
|
||||||
|
screenshot: message.screenshot
|
||||||
|
}).catch(error => {
|
||||||
|
console.log('Error broadcasting screenshot:', error);
|
||||||
|
});
|
||||||
|
sendResponse({
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('Unknown message type:', message.type);
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Unknown message type'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling message:', error);
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true; // Keep the message channel open for async response
|
||||||
|
});
|
||||||
|
/******/ })()
|
||||||
|
;
|
14
dist/content.css
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
#jira-feedback-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 999999;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#jira-feedback-overlay.active {
|
||||||
|
display: block;
|
||||||
|
}
|
7917
dist/content.js
vendored
Normal file
107
dist/editor-init.js
vendored
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
/******/ (() => { // webpackBootstrap
|
||||||
|
// Initialize the editor when the DOM content is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
console.log('Editor window loaded');
|
||||||
|
|
||||||
|
// Get canvas element
|
||||||
|
const canvas = document.getElementById('editor-canvas');
|
||||||
|
if (!canvas) {
|
||||||
|
console.error('Canvas element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Canvas element found:', canvas);
|
||||||
|
|
||||||
|
// Create test image to verify canvas
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = 800;
|
||||||
|
canvas.height = 600;
|
||||||
|
ctx.fillStyle = '#f0f0f0';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.font = '20px Arial';
|
||||||
|
ctx.fillText('Waiting for screenshot...', 20, 40);
|
||||||
|
|
||||||
|
// Initialize screenshot editor
|
||||||
|
const editor = new ScreenshotEditor(canvas);
|
||||||
|
console.log('ScreenshotEditor initialized');
|
||||||
|
|
||||||
|
// Notify background script that editor is ready
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'editor-ready'
|
||||||
|
}, response => {
|
||||||
|
console.log('Editor ready notification sent, response:', response);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Editor received message:', message);
|
||||||
|
if (message.type === 'init-editor') {
|
||||||
|
console.log('Loading screenshot...');
|
||||||
|
if (!message.screenshot || typeof message.screenshot !== 'string') {
|
||||||
|
console.error('Invalid screenshot data received');
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid screenshot data'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
console.log('Screenshot data is valid, dimensions:', img.width, 'x', img.height);
|
||||||
|
try {
|
||||||
|
await editor.loadImage(message.screenshot);
|
||||||
|
console.log('Screenshot loaded successfully');
|
||||||
|
sendResponse({
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading screenshot:', error);
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('Error loading image');
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Error loading image'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
img.src = message.screenshot;
|
||||||
|
return true; // Keep the message channel open for async response
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle save button click
|
||||||
|
document.getElementById('save-btn').addEventListener('click', () => {
|
||||||
|
console.log('Save button clicked');
|
||||||
|
try {
|
||||||
|
const editedScreenshot = editor.getImageData();
|
||||||
|
console.log('Sending edited screenshot back to popup...');
|
||||||
|
|
||||||
|
// Send the edited screenshot back to the background script
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'save-screenshot',
|
||||||
|
screenshot: editedScreenshot
|
||||||
|
}, response => {
|
||||||
|
console.log('Save screenshot response:', response);
|
||||||
|
if (response && response.success) {
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
console.error('Error saving screenshot:', response === null || response === void 0 ? void 0 : response.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving screenshot:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle cancel button click
|
||||||
|
document.getElementById('cancel-btn').addEventListener('click', () => {
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/******/ })()
|
||||||
|
;
|
111
dist/editor.html
vendored
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Screenshot Editor</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.editor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.tool-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.tool-button:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
.tool-button.active {
|
||||||
|
background: #2196F3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.canvas-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#editor-canvas {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.color-line-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.button-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#save-btn {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#cancel-btn {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="editor-container">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="select-tool" class="tool-button" title="Select (V)">Select</button>
|
||||||
|
<button id="move-tool" class="tool-button" title="Move (M)">Move</button>
|
||||||
|
<button id="pen-tool" class="tool-button" title="Pen (P)">Pen</button>
|
||||||
|
<button id="rectangle-tool" class="tool-button" title="Rectangle (R)">Rectangle</button>
|
||||||
|
<button id="arrow-tool" class="tool-button" title="Arrow (A)">Arrow</button>
|
||||||
|
<button id="crop-tool" class="tool-button" title="Crop (C)">Crop</button>
|
||||||
|
<div class="color-line-controls">
|
||||||
|
<input type="color" id="color-picker" value="#ff0000">
|
||||||
|
<input type="range" id="line-width" min="1" max="20" value="2">
|
||||||
|
</div>
|
||||||
|
<button id="undo-btn" title="Undo (Ctrl+Z)">Undo</button>
|
||||||
|
<button id="redo-btn" title="Redo (Ctrl+Y)">Redo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas id="editor-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<button id="cancel-btn" class="action-button">Cancel</button>
|
||||||
|
<button id="save-btn" class="action-button">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="screenshot-editor.js"></script>
|
||||||
|
<script src="editor-init.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
52
dist/manifest.json
vendored
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Jira Feedback",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "Submit feedback directly to Jira with screenshots and annotations",
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"activeTab",
|
||||||
|
"desktopCapture",
|
||||||
|
"notifications",
|
||||||
|
"scripting",
|
||||||
|
"tabs"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://*.atlassian.net/*"
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "assets/icons/icon16.png",
|
||||||
|
"48": "assets/icons/icon48.png",
|
||||||
|
"128": "assets/icons/icon128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "assets/icons/icon16.png",
|
||||||
|
"48": "assets/icons/icon48.png",
|
||||||
|
"128": "assets/icons/icon128.png"
|
||||||
|
},
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [{
|
||||||
|
"resources": [
|
||||||
|
"editor.html",
|
||||||
|
"js/*",
|
||||||
|
"styles/*",
|
||||||
|
"assets/*"
|
||||||
|
],
|
||||||
|
"matches": ["<all_urls>"]
|
||||||
|
}],
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["<all_urls>"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"css": ["content.css"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
}
|
||||||
|
}
|
81
dist/options.html
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Jira Feedback Extension Settings</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #0052CC;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #0747A6;
|
||||||
|
}
|
||||||
|
.notification {
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
background-color: #E3FCEF;
|
||||||
|
color: #006644;
|
||||||
|
border: 1px solid #ABF5D1;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background-color: #FFEBE6;
|
||||||
|
color: #DE350B;
|
||||||
|
border: 1px solid #FFBDAD;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Jira Settings</h2>
|
||||||
|
<div id="notification" class="notification"></div>
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-domain">Jira Domain</label>
|
||||||
|
<input type="text" id="jira-domain" placeholder="your-domain.atlassian.net" required>
|
||||||
|
<small>Example: your-company.atlassian.net</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-email">Jira Email</label>
|
||||||
|
<input type="text" id="jira-email" placeholder="your.email@company.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-token">API Token</label>
|
||||||
|
<input type="password" id="jira-token" required>
|
||||||
|
<small>Create an API token from your <a href="https://id.atlassian.com/manage/api-tokens" target="_blank">Atlassian Account Settings</a></small>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
78
dist/options.js
vendored
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/******/ (() => { // webpackBootstrap
|
||||||
|
class JiraSettings {
|
||||||
|
constructor() {
|
||||||
|
this.form = document.getElementById('settings-form');
|
||||||
|
this.notification = document.getElementById('notification');
|
||||||
|
this.domainInput = document.getElementById('jira-domain');
|
||||||
|
this.emailInput = document.getElementById('jira-email');
|
||||||
|
this.tokenInput = document.getElementById('jira-token');
|
||||||
|
this.loadSettings();
|
||||||
|
this.attachEventListeners();
|
||||||
|
}
|
||||||
|
attachEventListeners() {
|
||||||
|
this.form.addEventListener('submit', e => this.handleSubmit(e));
|
||||||
|
}
|
||||||
|
async loadSettings() {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']);
|
||||||
|
if (settings.jiraDomain) {
|
||||||
|
this.domainInput.value = settings.jiraDomain;
|
||||||
|
}
|
||||||
|
if (settings.jiraEmail) {
|
||||||
|
this.emailInput.value = settings.jiraEmail;
|
||||||
|
}
|
||||||
|
if (settings.jiraToken) {
|
||||||
|
this.tokenInput.value = settings.jiraToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const domain = this.domainInput.value.trim();
|
||||||
|
const email = this.emailInput.value.trim();
|
||||||
|
const token = this.tokenInput.value.trim();
|
||||||
|
try {
|
||||||
|
// Validate the credentials
|
||||||
|
const isValid = await this.validateJiraCredentials(domain, email, token);
|
||||||
|
if (isValid) {
|
||||||
|
// Save settings
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
jiraDomain: domain,
|
||||||
|
jiraEmail: email,
|
||||||
|
jiraToken: token
|
||||||
|
});
|
||||||
|
this.showNotification('Settings saved successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('Invalid Jira credentials. Please check and try again.', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
this.showNotification('Error saving settings. Please try again.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async validateJiraCredentials(domain, email, token) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${domain}/rest/api/3/myself`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${email}:${token}`)}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error validating Jira credentials:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showNotification(message, type) {
|
||||||
|
this.notification.textContent = message;
|
||||||
|
this.notification.className = `notification ${type}`;
|
||||||
|
this.notification.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.notification.style.display = 'none';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize settings
|
||||||
|
new JiraSettings();
|
||||||
|
/******/ })()
|
||||||
|
;
|
167
dist/popup.html
vendored
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Jira Feedback</title>
|
||||||
|
<link rel="stylesheet" href="styles/popup.css">
|
||||||
|
<style>
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.notification.error {
|
||||||
|
background-color: #FFEBE6;
|
||||||
|
color: #DE350B;
|
||||||
|
border: 1px solid #FFBDAD;
|
||||||
|
}
|
||||||
|
.notification.success {
|
||||||
|
background-color: #E3FCEF;
|
||||||
|
color: #006644;
|
||||||
|
border: 1px solid #ABF5D1;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
#screenshot-editor {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#screenshot-editor.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#editor-toolbar {
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#editor-canvas {
|
||||||
|
background: #fff;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 70vh;
|
||||||
|
border: 2px solid #ccc;
|
||||||
|
}
|
||||||
|
.tool-button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
background: #f4f5f7;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tool-button:hover {
|
||||||
|
background: #ebecf0;
|
||||||
|
}
|
||||||
|
.editor-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Settings Panel -->
|
||||||
|
<div id="settings-panel">
|
||||||
|
<h2>Jira Settings</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-domain">Jira Domain</label>
|
||||||
|
<input type="text" id="jira-domain" placeholder="your-domain.atlassian.net">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-email">Email</label>
|
||||||
|
<input type="email" id="jira-email" placeholder="your.email@company.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-token">API Token</label>
|
||||||
|
<input type="password" id="jira-token" placeholder="Your Jira API token">
|
||||||
|
<small>Get your API token from <a href="https://id.atlassian.com/manage/api-tokens" target="_blank">Atlassian Account Settings</a></small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-project">Project Key</label>
|
||||||
|
<input type="text" id="jira-project" placeholder="e.g., PROJ">
|
||||||
|
<small>This is the prefix of your Jira issues, like "PROJ" in "PROJ-123"</small>
|
||||||
|
</div>
|
||||||
|
<button id="save-settings" class="primary-button">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feedback Form -->
|
||||||
|
<div id="feedback-form" class="panel hidden">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="feedback-type">Type</label>
|
||||||
|
<select id="feedback-type">
|
||||||
|
<option value="Bug">Bug</option>
|
||||||
|
<option value="Feature">Story</option>
|
||||||
|
<option value="Improvement">Task</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="feedback-title">Title</label>
|
||||||
|
<input type="text" id="feedback-title" placeholder="Brief description">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="feedback-description">Description</label>
|
||||||
|
<textarea id="feedback-description" placeholder="Detailed description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Attachments</label>
|
||||||
|
<div class="attachment-buttons">
|
||||||
|
<button id="screenshot-btn" class="secondary-button">
|
||||||
|
<img src="assets/icons/screenshot.svg" alt="Screenshot">
|
||||||
|
Screenshot
|
||||||
|
</button>
|
||||||
|
<button id="record-btn" class="secondary-button">
|
||||||
|
<img src="assets/icons/record.svg" alt="Record">
|
||||||
|
Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="attachment-preview"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="submit-feedback" class="primary-button">Submit</button>
|
||||||
|
<button id="settings-btn" class="text-button">Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot Editor -->
|
||||||
|
<div id="screenshot-editor">
|
||||||
|
<div id="editor-toolbar"></div>
|
||||||
|
<canvas id="editor-canvas"></canvas>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button id="save-screenshot" class="primary-button">Save</button>
|
||||||
|
<button id="cancel-screenshot" class="secondary-button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="loading-overlay" class="hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="message">Processing...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification -->
|
||||||
|
<div id="notification" class="notification hidden">
|
||||||
|
<span class="message"></span>
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
799
dist/popup.js
vendored
Normal file
@ -0,0 +1,799 @@
|
|||||||
|
/******/ (() => { // webpackBootstrap
|
||||||
|
/******/ var __webpack_modules__ = ({
|
||||||
|
|
||||||
|
/***/ 169:
|
||||||
|
/***/ (() => {
|
||||||
|
|
||||||
|
class ScreenshotEditor {
|
||||||
|
constructor(canvas) {
|
||||||
|
console.log('Initializing ScreenshotEditor');
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d', {
|
||||||
|
alpha: false
|
||||||
|
});
|
||||||
|
this.isDrawing = false;
|
||||||
|
this.drawingMode = 'pen';
|
||||||
|
this.drawColor = '#ff0000';
|
||||||
|
this.lineWidth = 2;
|
||||||
|
this.startX = 0;
|
||||||
|
this.startY = 0;
|
||||||
|
this.lastX = 0;
|
||||||
|
this.lastY = 0;
|
||||||
|
this.undoStack = [];
|
||||||
|
this.redoStack = [];
|
||||||
|
this.shapes = [];
|
||||||
|
this.selectedShape = null;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.originalImage = null;
|
||||||
|
this.currentState = null;
|
||||||
|
this.scale = 1;
|
||||||
|
this.paths = [];
|
||||||
|
this.currentPath = [];
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupTools();
|
||||||
|
console.log('ScreenshotEditor initialized');
|
||||||
|
}
|
||||||
|
async loadImage(dataUrl) {
|
||||||
|
console.log('Loading image...');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!dataUrl || typeof dataUrl !== 'string') {
|
||||||
|
console.error('Invalid dataUrl:', dataUrl);
|
||||||
|
reject(new Error('Invalid image data'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
console.log('Image loaded, dimensions:', img.width, 'x', img.height);
|
||||||
|
this.originalImage = img;
|
||||||
|
|
||||||
|
// Use device pixel ratio for better resolution
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
// Calculate dimensions to maintain aspect ratio and high resolution
|
||||||
|
const maxWidth = Math.min(window.innerWidth * 0.98, img.width); // 98% of window width or original width
|
||||||
|
const maxHeight = Math.min(window.innerHeight * 0.9, img.height); // 90% of window height or original height
|
||||||
|
const aspectRatio = img.width / img.height;
|
||||||
|
let newWidth = img.width;
|
||||||
|
let newHeight = img.height;
|
||||||
|
|
||||||
|
// Scale down if necessary while maintaining aspect ratio
|
||||||
|
if (newWidth > maxWidth || newHeight > maxHeight) {
|
||||||
|
if (maxWidth / aspectRatio <= maxHeight) {
|
||||||
|
newWidth = maxWidth;
|
||||||
|
newHeight = maxWidth / aspectRatio;
|
||||||
|
} else {
|
||||||
|
newHeight = maxHeight;
|
||||||
|
newWidth = maxHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas size accounting for device pixel ratio
|
||||||
|
this.canvas.style.width = newWidth + 'px';
|
||||||
|
this.canvas.style.height = newHeight + 'px';
|
||||||
|
this.canvas.width = newWidth * dpr;
|
||||||
|
this.canvas.height = newHeight * dpr;
|
||||||
|
|
||||||
|
// Scale context for high DPI display
|
||||||
|
this.ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Calculate scale for proper coordinate mapping
|
||||||
|
this.scale = newWidth / img.width;
|
||||||
|
|
||||||
|
// Enable image smoothing
|
||||||
|
this.ctx.imageSmoothingEnabled = true;
|
||||||
|
this.ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
// Clear canvas and draw image with crisp rendering
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||||
|
|
||||||
|
// Save initial state
|
||||||
|
this.saveState();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
img.onerror = error => {
|
||||||
|
console.error('Error loading image:', error);
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
console.log('Setting image source...');
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupEventListeners() {
|
||||||
|
this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
|
||||||
|
this.canvas.addEventListener('mousemove', this.draw.bind(this));
|
||||||
|
this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
|
||||||
|
this.canvas.addEventListener('mouseleave', this.stopDrawing.bind(this));
|
||||||
|
|
||||||
|
// Setup keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.ctrlKey && e.key === 'z') {
|
||||||
|
this.undo();
|
||||||
|
} else if (e.ctrlKey && e.key === 'y') {
|
||||||
|
this.redo();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupTools() {
|
||||||
|
const tools = ['select', 'move', 'pen', 'rectangle', 'arrow', 'crop'];
|
||||||
|
tools.forEach(tool => {
|
||||||
|
const button = document.getElementById(`${tool}-tool`);
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
this.setDrawingMode(tool);
|
||||||
|
// Remove active class from all tools
|
||||||
|
tools.forEach(t => {
|
||||||
|
var _document$getElementB;
|
||||||
|
(_document$getElementB = document.getElementById(`${t}-tool`)) === null || _document$getElementB === void 0 || _document$getElementB.classList.remove('active');
|
||||||
|
});
|
||||||
|
// Add active class to selected tool
|
||||||
|
button.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup color picker
|
||||||
|
const colorPicker = document.getElementById('color-picker');
|
||||||
|
if (colorPicker) {
|
||||||
|
colorPicker.addEventListener('change', e => {
|
||||||
|
this.drawColor = e.target.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup line width
|
||||||
|
const lineWidth = document.getElementById('line-width');
|
||||||
|
if (lineWidth) {
|
||||||
|
lineWidth.addEventListener('change', e => {
|
||||||
|
this.lineWidth = parseInt(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup undo/redo buttons
|
||||||
|
const undoBtn = document.getElementById('undo-btn');
|
||||||
|
const redoBtn = document.getElementById('redo-btn');
|
||||||
|
if (undoBtn) undoBtn.addEventListener('click', () => this.undo());
|
||||||
|
if (redoBtn) redoBtn.addEventListener('click', () => this.redo());
|
||||||
|
}
|
||||||
|
startDrawing(e) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
this.isDrawing = true;
|
||||||
|
|
||||||
|
// Calculate coordinates taking into account DPR and canvas scaling
|
||||||
|
this.startX = (e.clientX - rect.left) * (this.canvas.width / (rect.width * dpr));
|
||||||
|
this.startY = (e.clientY - rect.top) * (this.canvas.height / (rect.height * dpr));
|
||||||
|
this.lastX = this.startX;
|
||||||
|
this.lastY = this.startY;
|
||||||
|
if (this.drawingMode === 'pen') {
|
||||||
|
// Start a new path array for the current drawing
|
||||||
|
this.currentPath = [];
|
||||||
|
this.currentPath.push({
|
||||||
|
x: this.startX,
|
||||||
|
y: this.startY
|
||||||
|
});
|
||||||
|
this.paths.push(this.currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw(e) {
|
||||||
|
if (!this.isDrawing) return;
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const currentX = (e.clientX - rect.left) * (this.canvas.width / (rect.width * dpr));
|
||||||
|
const currentY = (e.clientY - rect.top) * (this.canvas.height / (rect.height * dpr));
|
||||||
|
|
||||||
|
// Get the current state as an image
|
||||||
|
const baseImage = new Image();
|
||||||
|
baseImage.src = this.currentState || this.canvas.toDataURL();
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Draw the current state
|
||||||
|
if (this.currentState) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = this.currentState;
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
} else {
|
||||||
|
this.ctx.drawImage(this.originalImage, 0, 0, this.canvas.width / dpr, this.canvas.height / dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set drawing styles
|
||||||
|
this.ctx.strokeStyle = this.drawColor;
|
||||||
|
this.ctx.lineWidth = this.lineWidth;
|
||||||
|
this.ctx.lineCap = 'round';
|
||||||
|
this.ctx.lineJoin = 'round';
|
||||||
|
switch (this.drawingMode) {
|
||||||
|
case 'pen':
|
||||||
|
// Add point to current path
|
||||||
|
this.currentPath.push({
|
||||||
|
x: currentX,
|
||||||
|
y: currentY
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw the entire current path
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(this.currentPath[0].x, this.currentPath[0].y);
|
||||||
|
for (let i = 1; i < this.currentPath.length; i++) {
|
||||||
|
const point = this.currentPath[i];
|
||||||
|
this.ctx.lineTo(point.x, point.y);
|
||||||
|
}
|
||||||
|
this.ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'rectangle':
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.rect(this.startX, this.startY, currentX - this.startX, currentY - this.startY);
|
||||||
|
this.ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'arrow':
|
||||||
|
this.drawArrow(this.ctx, this.startX, this.startY, currentX, currentY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawArrow(ctx, fromX, fromY, toX, toY) {
|
||||||
|
const headLength = 20;
|
||||||
|
const angle = Math.atan2(toY - fromY, toX - fromX);
|
||||||
|
|
||||||
|
// Draw the line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(fromX, fromY);
|
||||||
|
ctx.lineTo(toX, toY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw the arrow head
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(toX, toY);
|
||||||
|
ctx.lineTo(toX - headLength * Math.cos(angle - Math.PI / 6), toY - headLength * Math.sin(angle - Math.PI / 6));
|
||||||
|
ctx.moveTo(toX, toY);
|
||||||
|
ctx.lineTo(toX - headLength * Math.cos(angle + Math.PI / 6), toY - headLength * Math.sin(angle + Math.PI / 6));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
stopDrawing() {
|
||||||
|
if (this.isDrawing) {
|
||||||
|
this.isDrawing = false;
|
||||||
|
// Save the current state with the completed drawing
|
||||||
|
this.currentState = this.canvas.toDataURL('image/png');
|
||||||
|
this.saveState();
|
||||||
|
// Reset current path
|
||||||
|
this.currentPath = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
if (this.undoStack.length > 1) {
|
||||||
|
this.redoStack.push(this.undoStack.pop()); // Move current state to redo stack
|
||||||
|
const previousState = this.undoStack[this.undoStack.length - 1];
|
||||||
|
this.loadStateImage(previousState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redo() {
|
||||||
|
if (this.redoStack.length > 0) {
|
||||||
|
const nextState = this.redoStack.pop();
|
||||||
|
this.undoStack.push(nextState);
|
||||||
|
this.loadStateImage(nextState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadStateImage(dataUrl) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
};
|
||||||
|
img.src = dataUrl;
|
||||||
|
}
|
||||||
|
getImageData() {
|
||||||
|
// Return the current state with drawings
|
||||||
|
return this.currentState || this.canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
setDrawingMode(mode) {
|
||||||
|
this.drawingMode = mode;
|
||||||
|
this.canvas.style.cursor = mode === 'crop' ? 'crosshair' : 'default';
|
||||||
|
}
|
||||||
|
saveState() {
|
||||||
|
const imageData = this.canvas.toDataURL('image/png');
|
||||||
|
this.undoStack.push(imageData);
|
||||||
|
this.redoStack = []; // Clear redo stack when new state is saved
|
||||||
|
}
|
||||||
|
redrawCanvas() {
|
||||||
|
if (!this.canvas.width || !this.canvas.height) {
|
||||||
|
console.warn('Canvas has no dimensions, skipping redraw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Draw the current state if it exists, otherwise draw the original image
|
||||||
|
if (this.currentState) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = this.currentState;
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
} else if (this.originalImage) {
|
||||||
|
this.ctx.drawImage(this.originalImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.ScreenshotEditor = ScreenshotEditor;
|
||||||
|
|
||||||
|
/***/ })
|
||||||
|
|
||||||
|
/******/ });
|
||||||
|
/************************************************************************/
|
||||||
|
/******/ // The module cache
|
||||||
|
/******/ var __webpack_module_cache__ = {};
|
||||||
|
/******/
|
||||||
|
/******/ // The require function
|
||||||
|
/******/ function __webpack_require__(moduleId) {
|
||||||
|
/******/ // Check if module is in cache
|
||||||
|
/******/ var cachedModule = __webpack_module_cache__[moduleId];
|
||||||
|
/******/ if (cachedModule !== undefined) {
|
||||||
|
/******/ return cachedModule.exports;
|
||||||
|
/******/ }
|
||||||
|
/******/ // Create a new module (and put it into the cache)
|
||||||
|
/******/ var module = __webpack_module_cache__[moduleId] = {
|
||||||
|
/******/ // no module.id needed
|
||||||
|
/******/ // no module.loaded needed
|
||||||
|
/******/ exports: {}
|
||||||
|
/******/ };
|
||||||
|
/******/
|
||||||
|
/******/ // Execute the module function
|
||||||
|
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
|
||||||
|
/******/
|
||||||
|
/******/ // Return the exports of the module
|
||||||
|
/******/ return module.exports;
|
||||||
|
/******/ }
|
||||||
|
/******/
|
||||||
|
/************************************************************************/
|
||||||
|
/******/ /* webpack/runtime/compat get default export */
|
||||||
|
/******/ (() => {
|
||||||
|
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||||
|
/******/ __webpack_require__.n = (module) => {
|
||||||
|
/******/ var getter = module && module.__esModule ?
|
||||||
|
/******/ () => (module['default']) :
|
||||||
|
/******/ () => (module);
|
||||||
|
/******/ __webpack_require__.d(getter, { a: getter });
|
||||||
|
/******/ return getter;
|
||||||
|
/******/ };
|
||||||
|
/******/ })();
|
||||||
|
/******/
|
||||||
|
/******/ /* webpack/runtime/define property getters */
|
||||||
|
/******/ (() => {
|
||||||
|
/******/ // define getter functions for harmony exports
|
||||||
|
/******/ __webpack_require__.d = (exports, definition) => {
|
||||||
|
/******/ for(var key in definition) {
|
||||||
|
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
|
||||||
|
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
|
||||||
|
/******/ }
|
||||||
|
/******/ }
|
||||||
|
/******/ };
|
||||||
|
/******/ })();
|
||||||
|
/******/
|
||||||
|
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||||
|
/******/ (() => {
|
||||||
|
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
|
||||||
|
/******/ })();
|
||||||
|
/******/
|
||||||
|
/************************************************************************/
|
||||||
|
// This entry needs to be wrapped in an IIFE because it needs to be in strict mode.
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
/* harmony import */ var _screenshot_editor_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(169);
|
||||||
|
/* harmony import */ var _screenshot_editor_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_screenshot_editor_js__WEBPACK_IMPORTED_MODULE_0__);
|
||||||
|
|
||||||
|
class FeedbackExtension {
|
||||||
|
constructor() {
|
||||||
|
this.screenshot = null;
|
||||||
|
this.editorWindow = null;
|
||||||
|
|
||||||
|
// Initialize when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
this.initializeElements();
|
||||||
|
this.attachEventListeners();
|
||||||
|
this.loadSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages from the editor window
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Popup received message:', message);
|
||||||
|
try {
|
||||||
|
if (message.type === 'screenshot-saved') {
|
||||||
|
this.screenshot = message.screenshot;
|
||||||
|
this.attachmentPreview.innerHTML = `
|
||||||
|
<div class="screenshot-preview">
|
||||||
|
<img src="${this.screenshot}" alt="Screenshot" style="max-width: 100%; height: auto;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// Close the editor window if it exists
|
||||||
|
if (this.editorWindow && !this.editorWindow.closed) {
|
||||||
|
this.editorWindow.close();
|
||||||
|
}
|
||||||
|
sendResponse({
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
} else if (message.type === 'editor-ready') {
|
||||||
|
console.log('Editor is ready, sending screenshot...');
|
||||||
|
if (this.screenshot) {
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'init-editor',
|
||||||
|
screenshot: this.screenshot
|
||||||
|
}, response => {
|
||||||
|
console.log('Screenshot sent to editor, response:', response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendResponse({
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling message:', error);
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true; // Keep the message channel open for async response
|
||||||
|
});
|
||||||
|
}
|
||||||
|
initializeElements() {
|
||||||
|
// Form elements
|
||||||
|
this.feedbackForm = document.getElementById('feedback-form');
|
||||||
|
this.settingsPanel = document.getElementById('settings-panel');
|
||||||
|
this.screenshotBtn = document.getElementById('screenshot-btn');
|
||||||
|
this.recordBtn = document.getElementById('record-btn');
|
||||||
|
this.submitFeedbackBtn = document.getElementById('submit-feedback');
|
||||||
|
this.settingsBtn = document.getElementById('settings-btn');
|
||||||
|
this.saveSettingsBtn = document.getElementById('save-settings');
|
||||||
|
this.attachmentPreview = document.getElementById('attachment-preview');
|
||||||
|
|
||||||
|
// Settings inputs
|
||||||
|
this.jiraDomainInput = document.getElementById('jira-domain');
|
||||||
|
this.jiraEmailInput = document.getElementById('jira-email');
|
||||||
|
this.jiraTokenInput = document.getElementById('jira-token');
|
||||||
|
this.jiraProjectInput = document.getElementById('jira-project');
|
||||||
|
|
||||||
|
// Notification
|
||||||
|
this.notification = document.getElementById('notification');
|
||||||
|
}
|
||||||
|
attachEventListeners() {
|
||||||
|
if (this.screenshotBtn) {
|
||||||
|
this.screenshotBtn.addEventListener('click', () => this.captureScreenshot());
|
||||||
|
}
|
||||||
|
if (this.recordBtn) {
|
||||||
|
this.recordBtn.addEventListener('click', () => this.startScreenRecording());
|
||||||
|
}
|
||||||
|
if (this.submitFeedbackBtn) {
|
||||||
|
this.submitFeedbackBtn.addEventListener('click', () => this.handleSubmit());
|
||||||
|
}
|
||||||
|
if (this.settingsBtn) {
|
||||||
|
this.settingsBtn.addEventListener('click', () => this.showSettings());
|
||||||
|
}
|
||||||
|
if (this.saveSettingsBtn) {
|
||||||
|
this.saveSettingsBtn.addEventListener('click', () => this.saveSettings());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async loadSettings() {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken', 'jiraProject']);
|
||||||
|
|
||||||
|
// Fill in settings if they exist
|
||||||
|
if (settings.jiraDomain) this.jiraDomainInput.value = settings.jiraDomain;
|
||||||
|
if (settings.jiraEmail) this.jiraEmailInput.value = settings.jiraEmail;
|
||||||
|
if (settings.jiraToken) this.jiraTokenInput.value = settings.jiraToken;
|
||||||
|
if (settings.jiraProject) this.jiraProjectInput.value = settings.jiraProject;
|
||||||
|
|
||||||
|
// Show appropriate panel based on whether settings exist
|
||||||
|
if (settings.jiraDomain && settings.jiraEmail && settings.jiraToken && settings.jiraProject) {
|
||||||
|
this.showFeedbackForm();
|
||||||
|
} else {
|
||||||
|
this.showSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async saveSettings() {
|
||||||
|
const domain = this.jiraDomainInput.value.trim();
|
||||||
|
const email = this.jiraEmailInput.value.trim();
|
||||||
|
const token = this.jiraTokenInput.value.trim();
|
||||||
|
const project = this.jiraProjectInput.value.trim();
|
||||||
|
if (!domain || !email || !token || !project) {
|
||||||
|
this.showNotification('Please fill in all fields', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Validate credentials by making a test API call
|
||||||
|
const isValid = await this.validateJiraCredentials(domain, email, token);
|
||||||
|
if (!isValid) {
|
||||||
|
this.showNotification('Invalid Jira credentials', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
jiraDomain: domain,
|
||||||
|
jiraEmail: email,
|
||||||
|
jiraToken: token,
|
||||||
|
jiraProject: project
|
||||||
|
});
|
||||||
|
this.showNotification('Settings saved successfully', 'success');
|
||||||
|
this.showFeedbackForm();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
this.showNotification('Error saving settings: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async validateJiraCredentials(domain, email, token) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${domain}/rest/api/2/myself`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${email}:${token}`)}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error validating Jira credentials:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async handleSubmit() {
|
||||||
|
try {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken', 'jiraProject']);
|
||||||
|
if (!settings.jiraDomain || !settings.jiraEmail || !settings.jiraToken || !settings.jiraProject) {
|
||||||
|
this.showNotification('Please configure Jira settings first', 'error');
|
||||||
|
this.showSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = document.getElementById('feedback-type').value;
|
||||||
|
const title = document.getElementById('feedback-title').value;
|
||||||
|
const description = document.getElementById('feedback-description').value;
|
||||||
|
if (!title || !description) {
|
||||||
|
this.showNotification('Please fill in all required fields', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map feedback type to standard Jira issue types
|
||||||
|
let issueType;
|
||||||
|
switch (type) {
|
||||||
|
case 'Bug':
|
||||||
|
issueType = 'Bug';
|
||||||
|
break;
|
||||||
|
case 'Feature':
|
||||||
|
issueType = 'Story';
|
||||||
|
break;
|
||||||
|
case 'Improvement':
|
||||||
|
issueType = 'Task';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
issueType = 'Task';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current tab URL for context
|
||||||
|
const [tab] = await chrome.tabs.query({
|
||||||
|
active: true,
|
||||||
|
currentWindow: true
|
||||||
|
});
|
||||||
|
const contextInfo = `
|
||||||
|
Reported from: ${tab.url}
|
||||||
|
Browser: ${navigator.userAgent}
|
||||||
|
Timestamp: ${new Date().toISOString()}
|
||||||
|
|
||||||
|
Description:
|
||||||
|
${description}
|
||||||
|
`;
|
||||||
|
const formData = {
|
||||||
|
fields: {
|
||||||
|
project: {
|
||||||
|
key: settings.jiraProject
|
||||||
|
},
|
||||||
|
summary: title,
|
||||||
|
description: contextInfo,
|
||||||
|
issuetype: {
|
||||||
|
name: issueType
|
||||||
|
},
|
||||||
|
labels: ["feedback-extension"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
console.log('Submitting to Jira with data:', JSON.stringify(formData, null, 2));
|
||||||
|
const response = await this.submitToJira(formData);
|
||||||
|
|
||||||
|
// If we have a screenshot, attach it to the created issue
|
||||||
|
if (this.screenshot && response.key) {
|
||||||
|
await this.attachScreenshotToIssue(response.key, this.screenshot);
|
||||||
|
}
|
||||||
|
if (response.key) {
|
||||||
|
this.showNotification(`Issue ${response.key} created successfully!`, 'success');
|
||||||
|
document.getElementById('feedback-title').value = '';
|
||||||
|
document.getElementById('feedback-description').value = '';
|
||||||
|
this.attachmentPreview.innerHTML = '';
|
||||||
|
this.screenshot = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Full error details:', error);
|
||||||
|
this.showNotification('Error submitting feedback: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async submitToJira(formData) {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']);
|
||||||
|
try {
|
||||||
|
// First, get available issue types for the project
|
||||||
|
const metaResponse = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue/createmeta?projectKeys=${formData.fields.project.key}&expand=projects.issuetypes.fields`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const metaData = await metaResponse.json();
|
||||||
|
console.log('Project metadata:', metaData);
|
||||||
|
if (!metaResponse.ok) {
|
||||||
|
throw new Error('Failed to get project metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available issue types
|
||||||
|
const project = metaData.projects[0];
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('Project not found or no access');
|
||||||
|
}
|
||||||
|
const availableTypes = project.issuetypes.map(type => type.name);
|
||||||
|
console.log('Available issue types:', availableTypes);
|
||||||
|
|
||||||
|
// If the desired issue type isn't available, fall back to Task
|
||||||
|
if (!availableTypes.includes(formData.fields.issuetype.name)) {
|
||||||
|
console.log(`Issue type ${formData.fields.issuetype.name} not available, falling back to Task`);
|
||||||
|
formData.fields.issuetype.name = 'Task';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the issue
|
||||||
|
const response = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
const responseData = await response.json();
|
||||||
|
console.log('Jira API Response:', responseData);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (responseData.errors) {
|
||||||
|
const errorMessage = Object.entries(responseData.errors).map(([field, error]) => `${field}: ${error}`).join(', ');
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
} else if (responseData.errorMessages) {
|
||||||
|
throw new Error(responseData.errorMessages.join(', '));
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return responseData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting to Jira:', error);
|
||||||
|
console.error('Request data:', JSON.stringify(formData, null, 2));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async attachScreenshotToIssue(issueKey, screenshotDataUrl) {
|
||||||
|
try {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']);
|
||||||
|
|
||||||
|
// Convert base64 data URL to blob
|
||||||
|
const base64Data = screenshotDataUrl.replace(/^data:image\/png;base64,/, '');
|
||||||
|
const byteCharacters = atob(base64Data);
|
||||||
|
const byteArrays = [];
|
||||||
|
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||||
|
const slice = byteCharacters.slice(offset, offset + 512);
|
||||||
|
const byteNumbers = new Array(slice.length);
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
byteNumbers[i] = slice.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const byteArray = new Uint8Array(byteNumbers);
|
||||||
|
byteArrays.push(byteArray);
|
||||||
|
}
|
||||||
|
const blob = new Blob(byteArrays, {
|
||||||
|
type: 'image/png'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create form data with the screenshot
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, 'screenshot.png');
|
||||||
|
|
||||||
|
// Upload the attachment
|
||||||
|
const response = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue/${issueKey}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`,
|
||||||
|
'X-Atlassian-Token': 'no-check' // Required for file uploads
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Failed to attach screenshot: ${error}`);
|
||||||
|
}
|
||||||
|
console.log('Screenshot attached successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error attaching screenshot:', error);
|
||||||
|
throw new Error('Failed to attach screenshot: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async captureScreenshot() {
|
||||||
|
try {
|
||||||
|
// Get current tab
|
||||||
|
const [tab] = await chrome.tabs.query({
|
||||||
|
active: true,
|
||||||
|
currentWindow: true
|
||||||
|
});
|
||||||
|
console.log('Current tab:', tab);
|
||||||
|
|
||||||
|
// Capture the visible tab
|
||||||
|
console.log('Capturing screenshot...');
|
||||||
|
const screenshot = await chrome.tabs.captureVisibleTab(null, {
|
||||||
|
format: 'png'
|
||||||
|
});
|
||||||
|
console.log('Screenshot captured:', (screenshot === null || screenshot === void 0 ? void 0 : screenshot.substring(0, 100)) + '...');
|
||||||
|
|
||||||
|
// Verify screenshot data
|
||||||
|
if (!screenshot || !screenshot.startsWith('data:image')) {
|
||||||
|
throw new Error('Invalid screenshot data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the screenshot data
|
||||||
|
this.screenshot = screenshot;
|
||||||
|
|
||||||
|
// Calculate window dimensions
|
||||||
|
const maxWidth = Math.min(1200, window.screen.availWidth * 0.8);
|
||||||
|
const maxHeight = Math.min(800, window.screen.availHeight * 0.8);
|
||||||
|
const left = (window.screen.availWidth - maxWidth) / 2;
|
||||||
|
const top = (window.screen.availHeight - maxHeight) / 2;
|
||||||
|
|
||||||
|
// Open editor window
|
||||||
|
console.log('Opening editor window...');
|
||||||
|
const editorUrl = chrome.runtime.getURL('editor.html');
|
||||||
|
this.editorWindow = window.open(editorUrl, 'screenshot-editor', `width=${maxWidth},height=${maxHeight},left=${left},top=${top},resizable=yes`);
|
||||||
|
if (!this.editorWindow) {
|
||||||
|
throw new Error('Could not open editor window. Please allow popups for this extension.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send screenshot to background script
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'init-editor',
|
||||||
|
screenshot: this.screenshot
|
||||||
|
}, response => {
|
||||||
|
console.log('Screenshot sent to background, response:', response);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Screenshot error:', error);
|
||||||
|
this.showNotification('Error capturing screenshot: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async startScreenRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: true
|
||||||
|
});
|
||||||
|
// Recording implementation here
|
||||||
|
this.showNotification('Screen recording feature coming soon!', 'info');
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('Error starting screen recording: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSettings() {
|
||||||
|
this.settingsPanel.classList.remove('hidden');
|
||||||
|
this.feedbackForm.classList.add('hidden');
|
||||||
|
}
|
||||||
|
showFeedbackForm() {
|
||||||
|
this.settingsPanel.classList.add('hidden');
|
||||||
|
this.feedbackForm.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
this.notification.textContent = message;
|
||||||
|
this.notification.className = `notification ${type}`;
|
||||||
|
this.notification.classList.remove('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.notification.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the extension
|
||||||
|
new FeedbackExtension();
|
||||||
|
})();
|
||||||
|
|
||||||
|
/******/ })()
|
||||||
|
;
|
311
dist/screenshot-editor.js
vendored
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
/******/ (() => { // webpackBootstrap
|
||||||
|
class ScreenshotEditor {
|
||||||
|
constructor(canvas) {
|
||||||
|
console.log('Initializing ScreenshotEditor');
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d', {
|
||||||
|
alpha: false
|
||||||
|
});
|
||||||
|
this.isDrawing = false;
|
||||||
|
this.drawingMode = 'pen';
|
||||||
|
this.drawColor = '#ff0000';
|
||||||
|
this.lineWidth = 2;
|
||||||
|
this.startX = 0;
|
||||||
|
this.startY = 0;
|
||||||
|
this.lastX = 0;
|
||||||
|
this.lastY = 0;
|
||||||
|
this.undoStack = [];
|
||||||
|
this.redoStack = [];
|
||||||
|
this.shapes = [];
|
||||||
|
this.selectedShape = null;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.originalImage = null;
|
||||||
|
this.currentState = null;
|
||||||
|
this.scale = 1;
|
||||||
|
this.paths = [];
|
||||||
|
this.currentPath = [];
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupTools();
|
||||||
|
console.log('ScreenshotEditor initialized');
|
||||||
|
}
|
||||||
|
async loadImage(dataUrl) {
|
||||||
|
console.log('Loading image...');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!dataUrl || typeof dataUrl !== 'string') {
|
||||||
|
console.error('Invalid dataUrl:', dataUrl);
|
||||||
|
reject(new Error('Invalid image data'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
console.log('Image loaded, dimensions:', img.width, 'x', img.height);
|
||||||
|
this.originalImage = img;
|
||||||
|
|
||||||
|
// Use device pixel ratio for better resolution
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
// Calculate dimensions to maintain aspect ratio and high resolution
|
||||||
|
const maxWidth = Math.min(window.innerWidth * 0.98, img.width); // 98% of window width or original width
|
||||||
|
const maxHeight = Math.min(window.innerHeight * 0.9, img.height); // 90% of window height or original height
|
||||||
|
const aspectRatio = img.width / img.height;
|
||||||
|
let newWidth = img.width;
|
||||||
|
let newHeight = img.height;
|
||||||
|
|
||||||
|
// Scale down if necessary while maintaining aspect ratio
|
||||||
|
if (newWidth > maxWidth || newHeight > maxHeight) {
|
||||||
|
if (maxWidth / aspectRatio <= maxHeight) {
|
||||||
|
newWidth = maxWidth;
|
||||||
|
newHeight = maxWidth / aspectRatio;
|
||||||
|
} else {
|
||||||
|
newHeight = maxHeight;
|
||||||
|
newWidth = maxHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas size accounting for device pixel ratio
|
||||||
|
this.canvas.style.width = newWidth + 'px';
|
||||||
|
this.canvas.style.height = newHeight + 'px';
|
||||||
|
this.canvas.width = newWidth * dpr;
|
||||||
|
this.canvas.height = newHeight * dpr;
|
||||||
|
|
||||||
|
// Scale context for high DPI display
|
||||||
|
this.ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Calculate scale for proper coordinate mapping
|
||||||
|
this.scale = newWidth / img.width;
|
||||||
|
|
||||||
|
// Enable image smoothing
|
||||||
|
this.ctx.imageSmoothingEnabled = true;
|
||||||
|
this.ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
// Clear canvas and draw image with crisp rendering
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||||
|
|
||||||
|
// Save initial state
|
||||||
|
this.saveState();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
img.onerror = error => {
|
||||||
|
console.error('Error loading image:', error);
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
console.log('Setting image source...');
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupEventListeners() {
|
||||||
|
this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
|
||||||
|
this.canvas.addEventListener('mousemove', this.draw.bind(this));
|
||||||
|
this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
|
||||||
|
this.canvas.addEventListener('mouseleave', this.stopDrawing.bind(this));
|
||||||
|
|
||||||
|
// Setup keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.ctrlKey && e.key === 'z') {
|
||||||
|
this.undo();
|
||||||
|
} else if (e.ctrlKey && e.key === 'y') {
|
||||||
|
this.redo();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupTools() {
|
||||||
|
const tools = ['select', 'move', 'pen', 'rectangle', 'arrow', 'crop'];
|
||||||
|
tools.forEach(tool => {
|
||||||
|
const button = document.getElementById(`${tool}-tool`);
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
this.setDrawingMode(tool);
|
||||||
|
// Remove active class from all tools
|
||||||
|
tools.forEach(t => {
|
||||||
|
var _document$getElementB;
|
||||||
|
(_document$getElementB = document.getElementById(`${t}-tool`)) === null || _document$getElementB === void 0 || _document$getElementB.classList.remove('active');
|
||||||
|
});
|
||||||
|
// Add active class to selected tool
|
||||||
|
button.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup color picker
|
||||||
|
const colorPicker = document.getElementById('color-picker');
|
||||||
|
if (colorPicker) {
|
||||||
|
colorPicker.addEventListener('change', e => {
|
||||||
|
this.drawColor = e.target.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup line width
|
||||||
|
const lineWidth = document.getElementById('line-width');
|
||||||
|
if (lineWidth) {
|
||||||
|
lineWidth.addEventListener('change', e => {
|
||||||
|
this.lineWidth = parseInt(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup undo/redo buttons
|
||||||
|
const undoBtn = document.getElementById('undo-btn');
|
||||||
|
const redoBtn = document.getElementById('redo-btn');
|
||||||
|
if (undoBtn) undoBtn.addEventListener('click', () => this.undo());
|
||||||
|
if (redoBtn) redoBtn.addEventListener('click', () => this.redo());
|
||||||
|
}
|
||||||
|
startDrawing(e) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
this.isDrawing = true;
|
||||||
|
|
||||||
|
// Calculate coordinates taking into account DPR and canvas scaling
|
||||||
|
this.startX = (e.clientX - rect.left) * (this.canvas.width / (rect.width * dpr));
|
||||||
|
this.startY = (e.clientY - rect.top) * (this.canvas.height / (rect.height * dpr));
|
||||||
|
this.lastX = this.startX;
|
||||||
|
this.lastY = this.startY;
|
||||||
|
if (this.drawingMode === 'pen') {
|
||||||
|
// Start a new path array for the current drawing
|
||||||
|
this.currentPath = [];
|
||||||
|
this.currentPath.push({
|
||||||
|
x: this.startX,
|
||||||
|
y: this.startY
|
||||||
|
});
|
||||||
|
this.paths.push(this.currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw(e) {
|
||||||
|
if (!this.isDrawing) return;
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const currentX = (e.clientX - rect.left) * (this.canvas.width / (rect.width * dpr));
|
||||||
|
const currentY = (e.clientY - rect.top) * (this.canvas.height / (rect.height * dpr));
|
||||||
|
|
||||||
|
// Get the current state as an image
|
||||||
|
const baseImage = new Image();
|
||||||
|
baseImage.src = this.currentState || this.canvas.toDataURL();
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Draw the current state
|
||||||
|
if (this.currentState) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = this.currentState;
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
} else {
|
||||||
|
this.ctx.drawImage(this.originalImage, 0, 0, this.canvas.width / dpr, this.canvas.height / dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set drawing styles
|
||||||
|
this.ctx.strokeStyle = this.drawColor;
|
||||||
|
this.ctx.lineWidth = this.lineWidth;
|
||||||
|
this.ctx.lineCap = 'round';
|
||||||
|
this.ctx.lineJoin = 'round';
|
||||||
|
switch (this.drawingMode) {
|
||||||
|
case 'pen':
|
||||||
|
// Add point to current path
|
||||||
|
this.currentPath.push({
|
||||||
|
x: currentX,
|
||||||
|
y: currentY
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw the entire current path
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(this.currentPath[0].x, this.currentPath[0].y);
|
||||||
|
for (let i = 1; i < this.currentPath.length; i++) {
|
||||||
|
const point = this.currentPath[i];
|
||||||
|
this.ctx.lineTo(point.x, point.y);
|
||||||
|
}
|
||||||
|
this.ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'rectangle':
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.rect(this.startX, this.startY, currentX - this.startX, currentY - this.startY);
|
||||||
|
this.ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'arrow':
|
||||||
|
this.drawArrow(this.ctx, this.startX, this.startY, currentX, currentY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawArrow(ctx, fromX, fromY, toX, toY) {
|
||||||
|
const headLength = 20;
|
||||||
|
const angle = Math.atan2(toY - fromY, toX - fromX);
|
||||||
|
|
||||||
|
// Draw the line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(fromX, fromY);
|
||||||
|
ctx.lineTo(toX, toY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw the arrow head
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(toX, toY);
|
||||||
|
ctx.lineTo(toX - headLength * Math.cos(angle - Math.PI / 6), toY - headLength * Math.sin(angle - Math.PI / 6));
|
||||||
|
ctx.moveTo(toX, toY);
|
||||||
|
ctx.lineTo(toX - headLength * Math.cos(angle + Math.PI / 6), toY - headLength * Math.sin(angle + Math.PI / 6));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
stopDrawing() {
|
||||||
|
if (this.isDrawing) {
|
||||||
|
this.isDrawing = false;
|
||||||
|
// Save the current state with the completed drawing
|
||||||
|
this.currentState = this.canvas.toDataURL('image/png');
|
||||||
|
this.saveState();
|
||||||
|
// Reset current path
|
||||||
|
this.currentPath = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
if (this.undoStack.length > 1) {
|
||||||
|
this.redoStack.push(this.undoStack.pop()); // Move current state to redo stack
|
||||||
|
const previousState = this.undoStack[this.undoStack.length - 1];
|
||||||
|
this.loadStateImage(previousState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redo() {
|
||||||
|
if (this.redoStack.length > 0) {
|
||||||
|
const nextState = this.redoStack.pop();
|
||||||
|
this.undoStack.push(nextState);
|
||||||
|
this.loadStateImage(nextState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadStateImage(dataUrl) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
};
|
||||||
|
img.src = dataUrl;
|
||||||
|
}
|
||||||
|
getImageData() {
|
||||||
|
// Return the current state with drawings
|
||||||
|
return this.currentState || this.canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
setDrawingMode(mode) {
|
||||||
|
this.drawingMode = mode;
|
||||||
|
this.canvas.style.cursor = mode === 'crop' ? 'crosshair' : 'default';
|
||||||
|
}
|
||||||
|
saveState() {
|
||||||
|
const imageData = this.canvas.toDataURL('image/png');
|
||||||
|
this.undoStack.push(imageData);
|
||||||
|
this.redoStack = []; // Clear redo stack when new state is saved
|
||||||
|
}
|
||||||
|
redrawCanvas() {
|
||||||
|
if (!this.canvas.width || !this.canvas.height) {
|
||||||
|
console.warn('Canvas has no dimensions, skipping redraw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Draw the current state if it exists, otherwise draw the original image
|
||||||
|
if (this.currentState) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = this.currentState;
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
} else if (this.originalImage) {
|
||||||
|
this.ctx.drawImage(this.originalImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.ScreenshotEditor = ScreenshotEditor;
|
||||||
|
/******/ })()
|
||||||
|
;
|
219
dist/styles/annotator.css
vendored
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
.canvas-container {
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group,
|
||||||
|
.color-group,
|
||||||
|
.size-group,
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn,
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover,
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn svg,
|
||||||
|
.action-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#color-picker {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#size-slider {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add separator between groups */
|
||||||
|
.tool-group:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: #ddd;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the done button differently */
|
||||||
|
#done-btn {
|
||||||
|
background: #4caf50;
|
||||||
|
border-color: #43a047;
|
||||||
|
}
|
||||||
|
|
||||||
|
#done-btn svg {
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#done-btn:hover {
|
||||||
|
background: #43a047;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
background: #F4F5F7;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#annotation-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(9, 30, 66, 0.13);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #DFE1E6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover {
|
||||||
|
background: #EBECF0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background: #DEEBFF;
|
||||||
|
border-color: #0052CC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #172B4D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-left: 1px solid #DFE1E6;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#color-picker {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stroke-width {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#done-btn {
|
||||||
|
background: #0052CC;
|
||||||
|
color: white;
|
||||||
|
border-color: #0052CC;
|
||||||
|
}
|
||||||
|
|
||||||
|
#done-btn:hover {
|
||||||
|
background: #0747A6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cancel-btn {
|
||||||
|
background: white;
|
||||||
|
color: #172B4D;
|
||||||
|
border-color: #DFE1E6;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cancel-btn:hover {
|
||||||
|
background: #EBECF0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
background: #F4F5F7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: crosshair;
|
||||||
|
box-shadow: 0 4px 8px rgba(9, 30, 66, 0.25);
|
||||||
|
}
|
287
dist/styles/popup.css
vendored
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #0052CC;
|
||||||
|
--secondary-color: #172B4D;
|
||||||
|
--background-color: #FFFFFF;
|
||||||
|
--border-color: #DFE1E6;
|
||||||
|
--success-color: #36B37E;
|
||||||
|
--error-color: #FF5630;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 400px;
|
||||||
|
min-height: 300px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
color: #172B4D;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #DFE1E6;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #6B778C;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: #0052CC;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
background: #0747A6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
background: #EBECF0;
|
||||||
|
color: #172B4D;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover {
|
||||||
|
background: #DFE1E6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #0052CC;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachment buttons */
|
||||||
|
.attachment-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-buttons img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
#loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(9, 30, 66, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #FFFFFF;
|
||||||
|
border-top: 3px solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification */
|
||||||
|
#notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #00875A;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification.error {
|
||||||
|
background: #DE350B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form actions */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth Container */
|
||||||
|
#auth-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn:hover {
|
||||||
|
background-color: #F4F5F7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Capture Options */
|
||||||
|
.capture-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn:hover {
|
||||||
|
background-color: #F4F5F7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feedback Form */
|
||||||
|
#feedback-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input, textarea {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-btn:hover {
|
||||||
|
background-color: #0747A6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachment Preview */
|
||||||
|
#attachment-preview {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jira Fields */
|
||||||
|
.jira-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
111
editor.html
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Screenshot Editor</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.editor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.tool-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.tool-button:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
.tool-button.active {
|
||||||
|
background: #2196F3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.canvas-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#editor-canvas {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.color-line-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.button-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#save-btn {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#cancel-btn {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="editor-container">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="select-tool" class="tool-button" title="Select (V)">Select</button>
|
||||||
|
<button id="move-tool" class="tool-button" title="Move (M)">Move</button>
|
||||||
|
<button id="pen-tool" class="tool-button" title="Pen (P)">Pen</button>
|
||||||
|
<button id="rectangle-tool" class="tool-button" title="Rectangle (R)">Rectangle</button>
|
||||||
|
<button id="arrow-tool" class="tool-button" title="Arrow (A)">Arrow</button>
|
||||||
|
<button id="crop-tool" class="tool-button" title="Crop (C)">Crop</button>
|
||||||
|
<div class="color-line-controls">
|
||||||
|
<input type="color" id="color-picker" value="#ff0000">
|
||||||
|
<input type="range" id="line-width" min="1" max="20" value="2">
|
||||||
|
</div>
|
||||||
|
<button id="undo-btn" title="Undo (Ctrl+Z)">Undo</button>
|
||||||
|
<button id="redo-btn" title="Redo (Ctrl+Y)">Redo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas id="editor-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<button id="cancel-btn" class="action-button">Cancel</button>
|
||||||
|
<button id="save-btn" class="action-button">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="screenshot-editor.js"></script>
|
||||||
|
<script src="editor-init.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
143
js/annotation.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Get screenshot from storage
|
||||||
|
const result = await chrome.storage.local.get(['tempScreenshot']);
|
||||||
|
if (!result.tempScreenshot) {
|
||||||
|
console.error('No screenshot found in storage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create image element
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
// Set canvas size to match image
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
|
||||||
|
// Draw image
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Drawing state
|
||||||
|
let isDrawing = false;
|
||||||
|
let currentTool = 'brush';
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let lastText = null;
|
||||||
|
|
||||||
|
// Tool button setup
|
||||||
|
document.querySelectorAll('.tool-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
// Remove active class from all buttons
|
||||||
|
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
// Add active class to clicked button
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentTool = btn.dataset.tool;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse event handlers
|
||||||
|
canvas.addEventListener('mousedown', startDrawing);
|
||||||
|
canvas.addEventListener('mousemove', draw);
|
||||||
|
canvas.addEventListener('mouseup', stopDrawing);
|
||||||
|
canvas.addEventListener('mouseout', stopDrawing);
|
||||||
|
|
||||||
|
function startDrawing(e) {
|
||||||
|
isDrawing = true;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
startX = e.clientX - rect.left;
|
||||||
|
startY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
if (currentTool === 'text') {
|
||||||
|
const text = prompt('Enter text:', '');
|
||||||
|
if (text) {
|
||||||
|
ctx.fillStyle = document.getElementById('color-picker').value;
|
||||||
|
ctx.font = '20px Arial';
|
||||||
|
ctx.fillText(text, startX, startY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(e) {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
ctx.strokeStyle = document.getElementById('color-picker').value;
|
||||||
|
ctx.lineWidth = document.getElementById('stroke-width').value;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
switch (currentTool) {
|
||||||
|
case 'brush':
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(startX, startY);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
startX = x;
|
||||||
|
startY = y;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rectangle':
|
||||||
|
// Clear previous preview
|
||||||
|
ctx.putImageData(lastImageData, 0, 0);
|
||||||
|
// Draw new rectangle
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(startX, startY, x - startX, y - startY);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'arrow':
|
||||||
|
// Clear previous preview
|
||||||
|
ctx.putImageData(lastImageData, 0, 0);
|
||||||
|
// Draw arrow line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(startX, startY);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
// Draw arrow head
|
||||||
|
const angle = Math.atan2(y - startY, x - startX);
|
||||||
|
const headLength = 20;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x - headLength * Math.cos(angle - Math.PI / 6), y - headLength * Math.sin(angle - Math.PI / 6));
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x - headLength * Math.cos(angle + Math.PI / 6), y - headLength * Math.sin(angle + Math.PI / 6));
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrawing() {
|
||||||
|
isDrawing = false;
|
||||||
|
// Save the current canvas state
|
||||||
|
lastImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save initial canvas state
|
||||||
|
let lastImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Set up done button
|
||||||
|
document.getElementById('done-btn').addEventListener('click', () => {
|
||||||
|
// Convert canvas to data URL
|
||||||
|
const dataUrl = canvas.toDataURL();
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
chrome.storage.local.set({ screenshot: dataUrl }, () => {
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up cancel button
|
||||||
|
document.getElementById('cancel-btn').addEventListener('click', () => {
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select brush tool by default
|
||||||
|
document.querySelector('[data-tool="brush"]').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load image
|
||||||
|
img.src = result.tempScreenshot;
|
||||||
|
});
|
289
js/annotator.js
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
import { Canvas, Image, Rect, Line, IText } from 'fabric';
|
||||||
|
|
||||||
|
export class ScreenshotAnnotator {
|
||||||
|
constructor() {
|
||||||
|
this.canvas = null;
|
||||||
|
this.currentTool = 'brush';
|
||||||
|
this.currentColor = '#ff0000';
|
||||||
|
this.currentSize = 5;
|
||||||
|
this.drawingMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(imageUrl, container) {
|
||||||
|
console.log('Initializing annotator with container:', container);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// Create canvas container
|
||||||
|
const canvasContainer = document.createElement('div');
|
||||||
|
canvasContainer.className = 'canvas-container';
|
||||||
|
|
||||||
|
// Create toolbar
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'annotation-toolbar';
|
||||||
|
toolbar.innerHTML = `
|
||||||
|
<div class="tool-group">
|
||||||
|
<button class="tool-btn active" data-tool="brush" title="Brush">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19H5v-1.92l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="text" title="Text">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="arrow" title="Arrow">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M16.01 11H4v2h12.01v3L20 12l-3.99-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" data-tool="rectangle" title="Rectangle">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-group">
|
||||||
|
<input type="color" id="color-picker" value="#ff0000" title="Color">
|
||||||
|
</div>
|
||||||
|
<div class="size-group">
|
||||||
|
<input type="range" id="size-slider" min="1" max="20" value="5" title="Size">
|
||||||
|
</div>
|
||||||
|
<div class="action-group">
|
||||||
|
<button class="action-btn" id="undo-btn" title="Undo">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" id="clear-btn" title="Clear">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" id="done-btn" title="Done">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create canvas wrapper for scrolling
|
||||||
|
const canvasWrapper = document.createElement('div');
|
||||||
|
canvasWrapper.className = 'canvas-wrapper';
|
||||||
|
|
||||||
|
// Create canvas
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.margin = 'auto';
|
||||||
|
canvas.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||||
|
|
||||||
|
// Add elements to DOM
|
||||||
|
canvasWrapper.appendChild(canvas);
|
||||||
|
canvasContainer.appendChild(toolbar);
|
||||||
|
canvasContainer.appendChild(canvasWrapper);
|
||||||
|
container.appendChild(canvasContainer);
|
||||||
|
|
||||||
|
console.log('Creating Fabric canvas');
|
||||||
|
// Initialize Fabric canvas
|
||||||
|
this.canvas = new Canvas(canvas);
|
||||||
|
|
||||||
|
// Load image
|
||||||
|
console.log('Loading image:', imageUrl.substring(0, 100) + '...');
|
||||||
|
const img = new window.Image();
|
||||||
|
|
||||||
|
img.onerror = (error) => {
|
||||||
|
console.error('Error loading image:', error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
console.log('Image loaded:', img.width, 'x', img.height);
|
||||||
|
try {
|
||||||
|
// Set canvas size to match window size while maintaining aspect ratio
|
||||||
|
const maxWidth = window.innerWidth - 40;
|
||||||
|
const maxHeight = window.innerHeight - 100;
|
||||||
|
|
||||||
|
const scale = Math.min(
|
||||||
|
maxWidth / img.width,
|
||||||
|
maxHeight / img.height
|
||||||
|
);
|
||||||
|
|
||||||
|
const width = img.width * scale;
|
||||||
|
const height = img.height * scale;
|
||||||
|
|
||||||
|
console.log('Setting canvas size:', width, 'x', height);
|
||||||
|
this.canvas.setWidth(width);
|
||||||
|
this.canvas.setHeight(height);
|
||||||
|
|
||||||
|
// Create Fabric image
|
||||||
|
console.log('Creating Fabric image');
|
||||||
|
Image.fromURL(imageUrl, (fabricImg) => {
|
||||||
|
try {
|
||||||
|
fabricImg.scale(scale);
|
||||||
|
fabricImg.selectable = false;
|
||||||
|
|
||||||
|
// Add image to canvas
|
||||||
|
this.canvas.add(fabricImg);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
this.setupEventListeners(toolbar);
|
||||||
|
|
||||||
|
console.log('Setup complete');
|
||||||
|
|
||||||
|
// Return promise that resolves when done button is clicked
|
||||||
|
document.getElementById('done-btn').addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
const dataUrl = this.canvas.toDataURL();
|
||||||
|
this.cleanup();
|
||||||
|
resolve(dataUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting data URL:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up Fabric image:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in image onload:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = imageUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in initialize:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners(toolbar) {
|
||||||
|
console.log('Setting up event listeners');
|
||||||
|
try {
|
||||||
|
// Tool buttons
|
||||||
|
const toolButtons = toolbar.querySelectorAll('.tool-btn');
|
||||||
|
toolButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
toolButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
this.currentTool = button.dataset.tool;
|
||||||
|
this.canvas.isDrawingMode = this.currentTool === 'brush';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color picker
|
||||||
|
const colorPicker = document.getElementById('color-picker');
|
||||||
|
colorPicker.addEventListener('change', (e) => {
|
||||||
|
this.currentColor = e.target.value;
|
||||||
|
if (this.canvas.isDrawingMode) {
|
||||||
|
this.canvas.freeDrawingBrush.color = this.currentColor;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Size slider
|
||||||
|
const sizeSlider = document.getElementById('size-slider');
|
||||||
|
sizeSlider.addEventListener('input', (e) => {
|
||||||
|
this.currentSize = parseInt(e.target.value);
|
||||||
|
if (this.canvas.isDrawingMode) {
|
||||||
|
this.canvas.freeDrawingBrush.width = this.currentSize;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undo button
|
||||||
|
document.getElementById('undo-btn').addEventListener('click', () => {
|
||||||
|
const objects = this.canvas.getObjects();
|
||||||
|
if (objects.length > 1) { // Keep background image
|
||||||
|
this.canvas.remove(objects[objects.length - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear button
|
||||||
|
document.getElementById('clear-btn').addEventListener('click', () => {
|
||||||
|
const objects = this.canvas.getObjects();
|
||||||
|
// Remove all objects except background image
|
||||||
|
for (let i = objects.length - 1; i > 0; i--) {
|
||||||
|
this.canvas.remove(objects[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial brush settings
|
||||||
|
this.canvas.freeDrawingBrush.color = this.currentColor;
|
||||||
|
this.canvas.freeDrawingBrush.width = this.currentSize;
|
||||||
|
|
||||||
|
// Mouse down handler for shapes and text
|
||||||
|
this.canvas.on('mouse:down', (options) => {
|
||||||
|
if (this.canvas.isDrawingMode) return;
|
||||||
|
|
||||||
|
const pointer = this.canvas.getPointer(options.e);
|
||||||
|
const startX = pointer.x;
|
||||||
|
const startY = pointer.y;
|
||||||
|
|
||||||
|
switch (this.currentTool) {
|
||||||
|
case 'rectangle':
|
||||||
|
const rect = new Rect({
|
||||||
|
left: startX,
|
||||||
|
top: startY,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
fill: 'transparent',
|
||||||
|
stroke: this.currentColor,
|
||||||
|
strokeWidth: this.currentSize
|
||||||
|
});
|
||||||
|
this.canvas.add(rect);
|
||||||
|
this.canvas.setActiveObject(rect);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'arrow':
|
||||||
|
const line = new Line([startX, startY, startX, startY], {
|
||||||
|
stroke: this.currentColor,
|
||||||
|
strokeWidth: this.currentSize
|
||||||
|
});
|
||||||
|
this.canvas.add(line);
|
||||||
|
this.canvas.setActiveObject(line);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
const text = new IText('Type here...', {
|
||||||
|
left: startX,
|
||||||
|
top: startY,
|
||||||
|
fill: this.currentColor,
|
||||||
|
fontSize: this.currentSize * 3
|
||||||
|
});
|
||||||
|
this.canvas.add(text);
|
||||||
|
text.enterEditing();
|
||||||
|
text.selectAll();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse move handler for shapes
|
||||||
|
this.canvas.on('mouse:move', (options) => {
|
||||||
|
if (!this.canvas.isDrawingMode && this.canvas.getActiveObject()) {
|
||||||
|
const pointer = this.canvas.getPointer(options.e);
|
||||||
|
const activeObj = this.canvas.getActiveObject();
|
||||||
|
|
||||||
|
if (this.currentTool === 'rectangle') {
|
||||||
|
const width = pointer.x - activeObj.left;
|
||||||
|
const height = pointer.y - activeObj.top;
|
||||||
|
activeObj.set({ width, height });
|
||||||
|
} else if (this.currentTool === 'arrow') {
|
||||||
|
const points = [activeObj.x1, activeObj.y1, pointer.x, pointer.y];
|
||||||
|
activeObj.set({ x2: pointer.x, y2: pointer.y });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up handler
|
||||||
|
this.canvas.on('mouse:up', () => {
|
||||||
|
this.canvas.setActiveObject(null);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in setupEventListeners:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
try {
|
||||||
|
// Remove event listeners and clean up resources
|
||||||
|
if (this.canvas) {
|
||||||
|
this.canvas.dispose();
|
||||||
|
this.canvas = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in cleanup:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
97
js/editor-init.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// Initialize the editor when the DOM content is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
console.log('Editor window loaded');
|
||||||
|
|
||||||
|
// Get canvas element
|
||||||
|
const canvas = document.getElementById('editor-canvas');
|
||||||
|
if (!canvas) {
|
||||||
|
console.error('Canvas element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Canvas element found:', canvas);
|
||||||
|
|
||||||
|
// Create test image to verify canvas
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = 800;
|
||||||
|
canvas.height = 600;
|
||||||
|
ctx.fillStyle = '#f0f0f0';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.font = '20px Arial';
|
||||||
|
ctx.fillText('Waiting for screenshot...', 20, 40);
|
||||||
|
|
||||||
|
// Initialize screenshot editor
|
||||||
|
const editor = new ScreenshotEditor(canvas);
|
||||||
|
console.log('ScreenshotEditor initialized');
|
||||||
|
|
||||||
|
// Notify background script that editor is ready
|
||||||
|
chrome.runtime.sendMessage({ type: 'editor-ready' }, response => {
|
||||||
|
console.log('Editor ready notification sent, response:', response);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Editor received message:', message);
|
||||||
|
|
||||||
|
if (message.type === 'init-editor') {
|
||||||
|
console.log('Loading screenshot...');
|
||||||
|
|
||||||
|
if (!message.screenshot || typeof message.screenshot !== 'string') {
|
||||||
|
console.error('Invalid screenshot data received');
|
||||||
|
sendResponse({ success: false, error: 'Invalid screenshot data' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
console.log('Screenshot data is valid, dimensions:', img.width, 'x', img.height);
|
||||||
|
try {
|
||||||
|
await editor.loadImage(message.screenshot);
|
||||||
|
console.log('Screenshot loaded successfully');
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading screenshot:', error);
|
||||||
|
sendResponse({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('Error loading image');
|
||||||
|
sendResponse({ success: false, error: 'Error loading image' });
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = message.screenshot;
|
||||||
|
return true; // Keep the message channel open for async response
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle save button click
|
||||||
|
document.getElementById('save-btn').addEventListener('click', () => {
|
||||||
|
console.log('Save button clicked');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const editedScreenshot = editor.getImageData();
|
||||||
|
console.log('Sending edited screenshot back to popup...');
|
||||||
|
|
||||||
|
// Send the edited screenshot back to the background script
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'save-screenshot',
|
||||||
|
screenshot: editedScreenshot
|
||||||
|
}, response => {
|
||||||
|
console.log('Save screenshot response:', response);
|
||||||
|
if (response && response.success) {
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
console.error('Error saving screenshot:', response?.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving screenshot:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle cancel button click
|
||||||
|
document.getElementById('cancel-btn').addEventListener('click', () => {
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
});
|
87
js/options.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
class JiraSettings {
|
||||||
|
constructor() {
|
||||||
|
this.form = document.getElementById('settings-form');
|
||||||
|
this.notification = document.getElementById('notification');
|
||||||
|
this.domainInput = document.getElementById('jira-domain');
|
||||||
|
this.emailInput = document.getElementById('jira-email');
|
||||||
|
this.tokenInput = document.getElementById('jira-token');
|
||||||
|
|
||||||
|
this.loadSettings();
|
||||||
|
this.attachEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventListeners() {
|
||||||
|
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']);
|
||||||
|
if (settings.jiraDomain) {
|
||||||
|
this.domainInput.value = settings.jiraDomain;
|
||||||
|
}
|
||||||
|
if (settings.jiraEmail) {
|
||||||
|
this.emailInput.value = settings.jiraEmail;
|
||||||
|
}
|
||||||
|
if (settings.jiraToken) {
|
||||||
|
this.tokenInput.value = settings.jiraToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const domain = this.domainInput.value.trim();
|
||||||
|
const email = this.emailInput.value.trim();
|
||||||
|
const token = this.tokenInput.value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate the credentials
|
||||||
|
const isValid = await this.validateJiraCredentials(domain, email, token);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
// Save settings
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
jiraDomain: domain,
|
||||||
|
jiraEmail: email,
|
||||||
|
jiraToken: token
|
||||||
|
});
|
||||||
|
|
||||||
|
this.showNotification('Settings saved successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('Invalid Jira credentials. Please check and try again.', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
this.showNotification('Error saving settings. Please try again.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateJiraCredentials(domain, email, token) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${domain}/rest/api/3/myself`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${email}:${token}`)}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error validating Jira credentials:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(message, type) {
|
||||||
|
this.notification.textContent = message;
|
||||||
|
this.notification.className = `notification ${type}`;
|
||||||
|
this.notification.style.display = 'block';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.notification.style.display = 'none';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize settings
|
||||||
|
new JiraSettings();
|
441
js/popup.js
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
import ScreenshotEditor from './screenshot-editor.js';
|
||||||
|
|
||||||
|
class FeedbackExtension {
|
||||||
|
constructor() {
|
||||||
|
this.screenshot = null;
|
||||||
|
this.editorWindow = null;
|
||||||
|
|
||||||
|
// Initialize when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
this.initializeElements();
|
||||||
|
this.attachEventListeners();
|
||||||
|
this.loadSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages from the editor window
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Popup received message:', message);
|
||||||
|
try {
|
||||||
|
if (message.type === 'screenshot-saved') {
|
||||||
|
this.screenshot = message.screenshot;
|
||||||
|
this.attachmentPreview.innerHTML = `
|
||||||
|
<div class="screenshot-preview">
|
||||||
|
<img src="${this.screenshot}" alt="Screenshot" style="max-width: 100%; height: auto;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// Close the editor window if it exists
|
||||||
|
if (this.editorWindow && !this.editorWindow.closed) {
|
||||||
|
this.editorWindow.close();
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} else if (message.type === 'editor-ready') {
|
||||||
|
console.log('Editor is ready, sending screenshot...');
|
||||||
|
if (this.screenshot) {
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'init-editor',
|
||||||
|
screenshot: this.screenshot
|
||||||
|
}, response => {
|
||||||
|
console.log('Screenshot sent to editor, response:', response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling message:', error);
|
||||||
|
sendResponse({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
return true; // Keep the message channel open for async response
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeElements() {
|
||||||
|
// Form elements
|
||||||
|
this.feedbackForm = document.getElementById('feedback-form');
|
||||||
|
this.settingsPanel = document.getElementById('settings-panel');
|
||||||
|
this.screenshotBtn = document.getElementById('screenshot-btn');
|
||||||
|
this.recordBtn = document.getElementById('record-btn');
|
||||||
|
this.submitFeedbackBtn = document.getElementById('submit-feedback');
|
||||||
|
this.settingsBtn = document.getElementById('settings-btn');
|
||||||
|
this.saveSettingsBtn = document.getElementById('save-settings');
|
||||||
|
this.attachmentPreview = document.getElementById('attachment-preview');
|
||||||
|
|
||||||
|
// Settings inputs
|
||||||
|
this.jiraDomainInput = document.getElementById('jira-domain');
|
||||||
|
this.jiraEmailInput = document.getElementById('jira-email');
|
||||||
|
this.jiraTokenInput = document.getElementById('jira-token');
|
||||||
|
this.jiraProjectInput = document.getElementById('jira-project');
|
||||||
|
|
||||||
|
// Notification
|
||||||
|
this.notification = document.getElementById('notification');
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventListeners() {
|
||||||
|
if (this.screenshotBtn) {
|
||||||
|
this.screenshotBtn.addEventListener('click', () => this.captureScreenshot());
|
||||||
|
}
|
||||||
|
if (this.recordBtn) {
|
||||||
|
this.recordBtn.addEventListener('click', () => this.startScreenRecording());
|
||||||
|
}
|
||||||
|
if (this.submitFeedbackBtn) {
|
||||||
|
this.submitFeedbackBtn.addEventListener('click', () => this.handleSubmit());
|
||||||
|
}
|
||||||
|
if (this.settingsBtn) {
|
||||||
|
this.settingsBtn.addEventListener('click', () => this.showSettings());
|
||||||
|
}
|
||||||
|
if (this.saveSettingsBtn) {
|
||||||
|
this.saveSettingsBtn.addEventListener('click', () => this.saveSettings());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken', 'jiraProject']);
|
||||||
|
|
||||||
|
// Fill in settings if they exist
|
||||||
|
if (settings.jiraDomain) this.jiraDomainInput.value = settings.jiraDomain;
|
||||||
|
if (settings.jiraEmail) this.jiraEmailInput.value = settings.jiraEmail;
|
||||||
|
if (settings.jiraToken) this.jiraTokenInput.value = settings.jiraToken;
|
||||||
|
if (settings.jiraProject) this.jiraProjectInput.value = settings.jiraProject;
|
||||||
|
|
||||||
|
// Show appropriate panel based on whether settings exist
|
||||||
|
if (settings.jiraDomain && settings.jiraEmail && settings.jiraToken && settings.jiraProject) {
|
||||||
|
this.showFeedbackForm();
|
||||||
|
} else {
|
||||||
|
this.showSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
const domain = this.jiraDomainInput.value.trim();
|
||||||
|
const email = this.jiraEmailInput.value.trim();
|
||||||
|
const token = this.jiraTokenInput.value.trim();
|
||||||
|
const project = this.jiraProjectInput.value.trim();
|
||||||
|
|
||||||
|
if (!domain || !email || !token || !project) {
|
||||||
|
this.showNotification('Please fill in all fields', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate credentials by making a test API call
|
||||||
|
const isValid = await this.validateJiraCredentials(domain, email, token);
|
||||||
|
if (!isValid) {
|
||||||
|
this.showNotification('Invalid Jira credentials', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
jiraDomain: domain,
|
||||||
|
jiraEmail: email,
|
||||||
|
jiraToken: token,
|
||||||
|
jiraProject: project
|
||||||
|
});
|
||||||
|
|
||||||
|
this.showNotification('Settings saved successfully', 'success');
|
||||||
|
this.showFeedbackForm();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
this.showNotification('Error saving settings: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateJiraCredentials(domain, email, token) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${domain}/rest/api/2/myself`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${email}:${token}`)}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error validating Jira credentials:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit() {
|
||||||
|
try {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken', 'jiraProject']);
|
||||||
|
if (!settings.jiraDomain || !settings.jiraEmail || !settings.jiraToken || !settings.jiraProject) {
|
||||||
|
this.showNotification('Please configure Jira settings first', 'error');
|
||||||
|
this.showSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = document.getElementById('feedback-type').value;
|
||||||
|
const title = document.getElementById('feedback-title').value;
|
||||||
|
const description = document.getElementById('feedback-description').value;
|
||||||
|
|
||||||
|
if (!title || !description) {
|
||||||
|
this.showNotification('Please fill in all required fields', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map feedback type to standard Jira issue types
|
||||||
|
let issueType;
|
||||||
|
switch (type) {
|
||||||
|
case 'Bug':
|
||||||
|
issueType = 'Bug';
|
||||||
|
break;
|
||||||
|
case 'Feature':
|
||||||
|
issueType = 'Story';
|
||||||
|
break;
|
||||||
|
case 'Improvement':
|
||||||
|
issueType = 'Task';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
issueType = 'Task';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current tab URL for context
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
const contextInfo = `
|
||||||
|
Reported from: ${tab.url}
|
||||||
|
Browser: ${navigator.userAgent}
|
||||||
|
Timestamp: ${new Date().toISOString()}
|
||||||
|
|
||||||
|
Description:
|
||||||
|
${description}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
fields: {
|
||||||
|
project: {
|
||||||
|
key: settings.jiraProject
|
||||||
|
},
|
||||||
|
summary: title,
|
||||||
|
description: contextInfo,
|
||||||
|
issuetype: {
|
||||||
|
name: issueType
|
||||||
|
},
|
||||||
|
labels: ["feedback-extension"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Submitting to Jira with data:', JSON.stringify(formData, null, 2));
|
||||||
|
const response = await this.submitToJira(formData);
|
||||||
|
|
||||||
|
// If we have a screenshot, attach it to the created issue
|
||||||
|
if (this.screenshot && response.key) {
|
||||||
|
await this.attachScreenshotToIssue(response.key, this.screenshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.key) {
|
||||||
|
this.showNotification(`Issue ${response.key} created successfully!`, 'success');
|
||||||
|
document.getElementById('feedback-title').value = '';
|
||||||
|
document.getElementById('feedback-description').value = '';
|
||||||
|
this.attachmentPreview.innerHTML = '';
|
||||||
|
this.screenshot = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Full error details:', error);
|
||||||
|
this.showNotification('Error submitting feedback: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitToJira(formData) {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, get available issue types for the project
|
||||||
|
const metaResponse = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue/createmeta?projectKeys=${formData.fields.project.key}&expand=projects.issuetypes.fields`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const metaData = await metaResponse.json();
|
||||||
|
console.log('Project metadata:', metaData);
|
||||||
|
|
||||||
|
if (!metaResponse.ok) {
|
||||||
|
throw new Error('Failed to get project metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available issue types
|
||||||
|
const project = metaData.projects[0];
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('Project not found or no access');
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableTypes = project.issuetypes.map(type => type.name);
|
||||||
|
console.log('Available issue types:', availableTypes);
|
||||||
|
|
||||||
|
// If the desired issue type isn't available, fall back to Task
|
||||||
|
if (!availableTypes.includes(formData.fields.issuetype.name)) {
|
||||||
|
console.log(`Issue type ${formData.fields.issuetype.name} not available, falling back to Task`);
|
||||||
|
formData.fields.issuetype.name = 'Task';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the issue
|
||||||
|
const response = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
console.log('Jira API Response:', responseData);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (responseData.errors) {
|
||||||
|
const errorMessage = Object.entries(responseData.errors)
|
||||||
|
.map(([field, error]) => `${field}: ${error}`)
|
||||||
|
.join(', ');
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
} else if (responseData.errorMessages) {
|
||||||
|
throw new Error(responseData.errorMessages.join(', '));
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting to Jira:', error);
|
||||||
|
console.error('Request data:', JSON.stringify(formData, null, 2));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async attachScreenshotToIssue(issueKey, screenshotDataUrl) {
|
||||||
|
try {
|
||||||
|
const settings = await chrome.storage.local.get(['jiraDomain', 'jiraEmail', 'jiraToken']);
|
||||||
|
|
||||||
|
// Convert base64 data URL to blob
|
||||||
|
const base64Data = screenshotDataUrl.replace(/^data:image\/png;base64,/, '');
|
||||||
|
const byteCharacters = atob(base64Data);
|
||||||
|
const byteArrays = [];
|
||||||
|
|
||||||
|
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||||
|
const slice = byteCharacters.slice(offset, offset + 512);
|
||||||
|
const byteNumbers = new Array(slice.length);
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
byteNumbers[i] = slice.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const byteArray = new Uint8Array(byteNumbers);
|
||||||
|
byteArrays.push(byteArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob(byteArrays, { type: 'image/png' });
|
||||||
|
|
||||||
|
// Create form data with the screenshot
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, 'screenshot.png');
|
||||||
|
|
||||||
|
// Upload the attachment
|
||||||
|
const response = await fetch(`https://${settings.jiraDomain}/rest/api/2/issue/${issueKey}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(`${settings.jiraEmail}:${settings.jiraToken}`)}`,
|
||||||
|
'X-Atlassian-Token': 'no-check' // Required for file uploads
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Failed to attach screenshot: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Screenshot attached successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error attaching screenshot:', error);
|
||||||
|
throw new Error('Failed to attach screenshot: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureScreenshot() {
|
||||||
|
try {
|
||||||
|
// Get current tab
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
console.log('Current tab:', tab);
|
||||||
|
|
||||||
|
// Capture the visible tab
|
||||||
|
console.log('Capturing screenshot...');
|
||||||
|
const screenshot = await chrome.tabs.captureVisibleTab(null, {
|
||||||
|
format: 'png'
|
||||||
|
});
|
||||||
|
console.log('Screenshot captured:', screenshot?.substring(0, 100) + '...');
|
||||||
|
|
||||||
|
// Verify screenshot data
|
||||||
|
if (!screenshot || !screenshot.startsWith('data:image')) {
|
||||||
|
throw new Error('Invalid screenshot data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the screenshot data
|
||||||
|
this.screenshot = screenshot;
|
||||||
|
|
||||||
|
// Calculate window dimensions
|
||||||
|
const maxWidth = Math.min(1200, window.screen.availWidth * 0.8);
|
||||||
|
const maxHeight = Math.min(800, window.screen.availHeight * 0.8);
|
||||||
|
const left = (window.screen.availWidth - maxWidth) / 2;
|
||||||
|
const top = (window.screen.availHeight - maxHeight) / 2;
|
||||||
|
|
||||||
|
// Open editor window
|
||||||
|
console.log('Opening editor window...');
|
||||||
|
const editorUrl = chrome.runtime.getURL('editor.html');
|
||||||
|
this.editorWindow = window.open(
|
||||||
|
editorUrl,
|
||||||
|
'screenshot-editor',
|
||||||
|
`width=${maxWidth},height=${maxHeight},left=${left},top=${top},resizable=yes`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.editorWindow) {
|
||||||
|
throw new Error('Could not open editor window. Please allow popups for this extension.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send screenshot to background script
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'init-editor',
|
||||||
|
screenshot: this.screenshot
|
||||||
|
}, response => {
|
||||||
|
console.log('Screenshot sent to background, response:', response);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Screenshot error:', error);
|
||||||
|
this.showNotification('Error capturing screenshot: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startScreenRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||||
|
// Recording implementation here
|
||||||
|
this.showNotification('Screen recording feature coming soon!', 'info');
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('Error starting screen recording: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showSettings() {
|
||||||
|
this.settingsPanel.classList.remove('hidden');
|
||||||
|
this.feedbackForm.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
showFeedbackForm() {
|
||||||
|
this.settingsPanel.classList.add('hidden');
|
||||||
|
this.feedbackForm.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
this.notification.textContent = message;
|
||||||
|
this.notification.className = `notification ${type}`;
|
||||||
|
this.notification.classList.remove('hidden');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.notification.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the extension
|
||||||
|
new FeedbackExtension();
|
342
js/screenshot-editor.js
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
class ScreenshotEditor {
|
||||||
|
constructor(canvas) {
|
||||||
|
console.log('Initializing ScreenshotEditor');
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d', { alpha: false });
|
||||||
|
this.isDrawing = false;
|
||||||
|
this.drawingMode = 'pen';
|
||||||
|
this.drawColor = '#ff0000';
|
||||||
|
this.lineWidth = 2;
|
||||||
|
this.startX = 0;
|
||||||
|
this.startY = 0;
|
||||||
|
this.lastX = 0;
|
||||||
|
this.lastY = 0;
|
||||||
|
this.undoStack = [];
|
||||||
|
this.redoStack = [];
|
||||||
|
this.shapes = [];
|
||||||
|
this.selectedShape = null;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.originalImage = null;
|
||||||
|
this.currentState = null;
|
||||||
|
this.scale = 1;
|
||||||
|
this.paths = [];
|
||||||
|
this.currentPath = [];
|
||||||
|
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupTools();
|
||||||
|
console.log('ScreenshotEditor initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadImage(dataUrl) {
|
||||||
|
console.log('Loading image...');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!dataUrl || typeof dataUrl !== 'string') {
|
||||||
|
console.error('Invalid dataUrl:', dataUrl);
|
||||||
|
reject(new Error('Invalid image data'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
console.log('Image loaded, dimensions:', img.width, 'x', img.height);
|
||||||
|
this.originalImage = img;
|
||||||
|
|
||||||
|
// Use device pixel ratio for better resolution
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
|
// Calculate dimensions to maintain aspect ratio and high resolution
|
||||||
|
const maxWidth = Math.min(window.innerWidth * 0.98, img.width); // 98% of window width or original width
|
||||||
|
const maxHeight = Math.min(window.innerHeight * 0.9, img.height); // 90% of window height or original height
|
||||||
|
const aspectRatio = img.width / img.height;
|
||||||
|
|
||||||
|
let newWidth = img.width;
|
||||||
|
let newHeight = img.height;
|
||||||
|
|
||||||
|
// Scale down if necessary while maintaining aspect ratio
|
||||||
|
if (newWidth > maxWidth || newHeight > maxHeight) {
|
||||||
|
if (maxWidth / aspectRatio <= maxHeight) {
|
||||||
|
newWidth = maxWidth;
|
||||||
|
newHeight = maxWidth / aspectRatio;
|
||||||
|
} else {
|
||||||
|
newHeight = maxHeight;
|
||||||
|
newWidth = maxHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas size accounting for device pixel ratio
|
||||||
|
this.canvas.style.width = newWidth + 'px';
|
||||||
|
this.canvas.style.height = newHeight + 'px';
|
||||||
|
this.canvas.width = newWidth * dpr;
|
||||||
|
this.canvas.height = newHeight * dpr;
|
||||||
|
|
||||||
|
// Scale context for high DPI display
|
||||||
|
this.ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Calculate scale for proper coordinate mapping
|
||||||
|
this.scale = newWidth / img.width;
|
||||||
|
|
||||||
|
// Enable image smoothing
|
||||||
|
this.ctx.imageSmoothingEnabled = true;
|
||||||
|
this.ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
// Clear canvas and draw image with crisp rendering
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||||
|
|
||||||
|
// Save initial state
|
||||||
|
this.saveState();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = (error) => {
|
||||||
|
console.error('Error loading image:', error);
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Setting image source...');
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
|
||||||
|
this.canvas.addEventListener('mousemove', this.draw.bind(this));
|
||||||
|
this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
|
||||||
|
this.canvas.addEventListener('mouseleave', this.stopDrawing.bind(this));
|
||||||
|
|
||||||
|
// Setup keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.ctrlKey && e.key === 'z') {
|
||||||
|
this.undo();
|
||||||
|
} else if (e.ctrlKey && e.key === 'y') {
|
||||||
|
this.redo();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupTools() {
|
||||||
|
const tools = ['select', 'move', 'pen', 'rectangle', 'arrow', 'crop'];
|
||||||
|
tools.forEach(tool => {
|
||||||
|
const button = document.getElementById(`${tool}-tool`);
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
this.setDrawingMode(tool);
|
||||||
|
// Remove active class from all tools
|
||||||
|
tools.forEach(t => {
|
||||||
|
document.getElementById(`${t}-tool`)?.classList.remove('active');
|
||||||
|
});
|
||||||
|
// Add active class to selected tool
|
||||||
|
button.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup color picker
|
||||||
|
const colorPicker = document.getElementById('color-picker');
|
||||||
|
if (colorPicker) {
|
||||||
|
colorPicker.addEventListener('change', (e) => {
|
||||||
|
this.drawColor = e.target.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup line width
|
||||||
|
const lineWidth = document.getElementById('line-width');
|
||||||
|
if (lineWidth) {
|
||||||
|
lineWidth.addEventListener('change', (e) => {
|
||||||
|
this.lineWidth = parseInt(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup undo/redo buttons
|
||||||
|
const undoBtn = document.getElementById('undo-btn');
|
||||||
|
const redoBtn = document.getElementById('redo-btn');
|
||||||
|
if (undoBtn) undoBtn.addEventListener('click', () => this.undo());
|
||||||
|
if (redoBtn) redoBtn.addEventListener('click', () => this.redo());
|
||||||
|
}
|
||||||
|
|
||||||
|
startDrawing(e) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
this.isDrawing = true;
|
||||||
|
|
||||||
|
// Calculate coordinates taking into account DPR and canvas scaling
|
||||||
|
this.startX = (e.clientX - rect.left) * (this.canvas.width / (rect.width * dpr));
|
||||||
|
this.startY = (e.clientY - rect.top) * (this.canvas.height / (rect.height * dpr));
|
||||||
|
this.lastX = this.startX;
|
||||||
|
this.lastY = this.startY;
|
||||||
|
|
||||||
|
if (this.drawingMode === 'pen') {
|
||||||
|
// Start a new path array for the current drawing
|
||||||
|
this.currentPath = [];
|
||||||
|
this.currentPath.push({ x: this.startX, y: this.startY });
|
||||||
|
this.paths.push(this.currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(e) {
|
||||||
|
if (!this.isDrawing) return;
|
||||||
|
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const currentX = (e.clientX - rect.left) * (this.canvas.width / (rect.width * dpr));
|
||||||
|
const currentY = (e.clientY - rect.top) * (this.canvas.height / (rect.height * dpr));
|
||||||
|
|
||||||
|
// Get the current state as an image
|
||||||
|
const baseImage = new Image();
|
||||||
|
baseImage.src = this.currentState || this.canvas.toDataURL();
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Draw the current state
|
||||||
|
if (this.currentState) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = this.currentState;
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
} else {
|
||||||
|
this.ctx.drawImage(this.originalImage, 0, 0, this.canvas.width / dpr, this.canvas.height / dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set drawing styles
|
||||||
|
this.ctx.strokeStyle = this.drawColor;
|
||||||
|
this.ctx.lineWidth = this.lineWidth;
|
||||||
|
this.ctx.lineCap = 'round';
|
||||||
|
this.ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
switch (this.drawingMode) {
|
||||||
|
case 'pen':
|
||||||
|
// Add point to current path
|
||||||
|
this.currentPath.push({ x: currentX, y: currentY });
|
||||||
|
|
||||||
|
// Draw the entire current path
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(this.currentPath[0].x, this.currentPath[0].y);
|
||||||
|
for (let i = 1; i < this.currentPath.length; i++) {
|
||||||
|
const point = this.currentPath[i];
|
||||||
|
this.ctx.lineTo(point.x, point.y);
|
||||||
|
}
|
||||||
|
this.ctx.stroke();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rectangle':
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.rect(
|
||||||
|
this.startX,
|
||||||
|
this.startY,
|
||||||
|
currentX - this.startX,
|
||||||
|
currentY - this.startY
|
||||||
|
);
|
||||||
|
this.ctx.stroke();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'arrow':
|
||||||
|
this.drawArrow(this.ctx, this.startX, this.startY, currentX, currentY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawArrow(ctx, fromX, fromY, toX, toY) {
|
||||||
|
const headLength = 20;
|
||||||
|
const angle = Math.atan2(toY - fromY, toX - fromX);
|
||||||
|
|
||||||
|
// Draw the line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(fromX, fromY);
|
||||||
|
ctx.lineTo(toX, toY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw the arrow head
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(toX, toY);
|
||||||
|
ctx.lineTo(
|
||||||
|
toX - headLength * Math.cos(angle - Math.PI / 6),
|
||||||
|
toY - headLength * Math.sin(angle - Math.PI / 6)
|
||||||
|
);
|
||||||
|
ctx.moveTo(toX, toY);
|
||||||
|
ctx.lineTo(
|
||||||
|
toX - headLength * Math.cos(angle + Math.PI / 6),
|
||||||
|
toY - headLength * Math.sin(angle + Math.PI / 6)
|
||||||
|
);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDrawing() {
|
||||||
|
if (this.isDrawing) {
|
||||||
|
this.isDrawing = false;
|
||||||
|
// Save the current state with the completed drawing
|
||||||
|
this.currentState = this.canvas.toDataURL('image/png');
|
||||||
|
this.saveState();
|
||||||
|
// Reset current path
|
||||||
|
this.currentPath = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (this.undoStack.length > 1) {
|
||||||
|
this.redoStack.push(this.undoStack.pop()); // Move current state to redo stack
|
||||||
|
const previousState = this.undoStack[this.undoStack.length - 1];
|
||||||
|
this.loadStateImage(previousState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redo() {
|
||||||
|
if (this.redoStack.length > 0) {
|
||||||
|
const nextState = this.redoStack.pop();
|
||||||
|
this.undoStack.push(nextState);
|
||||||
|
this.loadStateImage(nextState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStateImage(dataUrl) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
};
|
||||||
|
img.src = dataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageData() {
|
||||||
|
// Return the current state with drawings
|
||||||
|
return this.currentState || this.canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrawingMode(mode) {
|
||||||
|
this.drawingMode = mode;
|
||||||
|
this.canvas.style.cursor = mode === 'crop' ? 'crosshair' : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState() {
|
||||||
|
const imageData = this.canvas.toDataURL('image/png');
|
||||||
|
this.undoStack.push(imageData);
|
||||||
|
this.redoStack = []; // Clear redo stack when new state is saved
|
||||||
|
}
|
||||||
|
|
||||||
|
redrawCanvas() {
|
||||||
|
if (!this.canvas.width || !this.canvas.height) {
|
||||||
|
console.warn('Canvas has no dimensions, skipping redraw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Draw the current state if it exists, otherwise draw the original image
|
||||||
|
if (this.currentState) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = this.currentState;
|
||||||
|
this.ctx.drawImage(img, 0, 0);
|
||||||
|
} else if (this.originalImage) {
|
||||||
|
this.ctx.drawImage(
|
||||||
|
this.originalImage,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this.canvas.width,
|
||||||
|
this.canvas.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ScreenshotEditor = ScreenshotEditor;
|
52
manifest.json
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Jira Feedback",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "Submit feedback directly to Jira with screenshots and annotations",
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"activeTab",
|
||||||
|
"desktopCapture",
|
||||||
|
"notifications",
|
||||||
|
"scripting",
|
||||||
|
"tabs"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://*.atlassian.net/*"
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "assets/icons/icon16.png",
|
||||||
|
"48": "assets/icons/icon48.png",
|
||||||
|
"128": "assets/icons/icon128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "assets/icons/icon16.png",
|
||||||
|
"48": "assets/icons/icon48.png",
|
||||||
|
"128": "assets/icons/icon128.png"
|
||||||
|
},
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [{
|
||||||
|
"resources": [
|
||||||
|
"editor.html",
|
||||||
|
"js/*",
|
||||||
|
"styles/*",
|
||||||
|
"assets/*"
|
||||||
|
],
|
||||||
|
"matches": ["<all_urls>"]
|
||||||
|
}],
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["<all_urls>"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"css": ["content.css"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
}
|
||||||
|
}
|
81
options.html
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Jira Feedback Extension Settings</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #0052CC;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #0747A6;
|
||||||
|
}
|
||||||
|
.notification {
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
background-color: #E3FCEF;
|
||||||
|
color: #006644;
|
||||||
|
border: 1px solid #ABF5D1;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background-color: #FFEBE6;
|
||||||
|
color: #DE350B;
|
||||||
|
border: 1px solid #FFBDAD;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Jira Settings</h2>
|
||||||
|
<div id="notification" class="notification"></div>
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-domain">Jira Domain</label>
|
||||||
|
<input type="text" id="jira-domain" placeholder="your-domain.atlassian.net" required>
|
||||||
|
<small>Example: your-company.atlassian.net</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-email">Jira Email</label>
|
||||||
|
<input type="text" id="jira-email" placeholder="your.email@company.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-token">API Token</label>
|
||||||
|
<input type="password" id="jira-token" required>
|
||||||
|
<small>Create an API token from your <a href="https://id.atlassian.com/manage/api-tokens" target="_blank">Atlassian Account Settings</a></small>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
11519
package-lock.json
generated
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "jira-feedback-extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Chrome extension for capturing and submitting feedback directly to Jira",
|
||||||
|
"main": "background.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/generate-icons.js && webpack --mode production",
|
||||||
|
"dev": "node scripts/generate-icons.js && webpack serve --mode development",
|
||||||
|
"icons": "node scripts/generate-icons.js",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fabric": "^6.5.4",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jira-feedback-extension": "file:",
|
||||||
|
"jwt-decode": "^3.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.26.0",
|
||||||
|
"@babel/plugin-transform-runtime": "^7.25.9",
|
||||||
|
"@babel/preset-env": "^7.26.0",
|
||||||
|
"@types/chrome": "^0.0.242",
|
||||||
|
"@types/jest": "^29.5.2",
|
||||||
|
"babel-loader": "^9.2.1",
|
||||||
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"sharp": "^0.32.6",
|
||||||
|
"svgexport": "^0.4.2",
|
||||||
|
"webpack": "^5.97.1",
|
||||||
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-dev-server": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
167
popup.html
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Jira Feedback</title>
|
||||||
|
<link rel="stylesheet" href="styles/popup.css">
|
||||||
|
<style>
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.notification.error {
|
||||||
|
background-color: #FFEBE6;
|
||||||
|
color: #DE350B;
|
||||||
|
border: 1px solid #FFBDAD;
|
||||||
|
}
|
||||||
|
.notification.success {
|
||||||
|
background-color: #E3FCEF;
|
||||||
|
color: #006644;
|
||||||
|
border: 1px solid #ABF5D1;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
#screenshot-editor {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#screenshot-editor.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#editor-toolbar {
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#editor-canvas {
|
||||||
|
background: #fff;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 70vh;
|
||||||
|
border: 2px solid #ccc;
|
||||||
|
}
|
||||||
|
.tool-button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
background: #f4f5f7;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tool-button:hover {
|
||||||
|
background: #ebecf0;
|
||||||
|
}
|
||||||
|
.editor-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Settings Panel -->
|
||||||
|
<div id="settings-panel">
|
||||||
|
<h2>Jira Settings</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-domain">Jira Domain</label>
|
||||||
|
<input type="text" id="jira-domain" placeholder="your-domain.atlassian.net">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-email">Email</label>
|
||||||
|
<input type="email" id="jira-email" placeholder="your.email@company.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-token">API Token</label>
|
||||||
|
<input type="password" id="jira-token" placeholder="Your Jira API token">
|
||||||
|
<small>Get your API token from <a href="https://id.atlassian.com/manage/api-tokens" target="_blank">Atlassian Account Settings</a></small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="jira-project">Project Key</label>
|
||||||
|
<input type="text" id="jira-project" placeholder="e.g., PROJ">
|
||||||
|
<small>This is the prefix of your Jira issues, like "PROJ" in "PROJ-123"</small>
|
||||||
|
</div>
|
||||||
|
<button id="save-settings" class="primary-button">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feedback Form -->
|
||||||
|
<div id="feedback-form" class="panel hidden">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="feedback-type">Type</label>
|
||||||
|
<select id="feedback-type">
|
||||||
|
<option value="Bug">Bug</option>
|
||||||
|
<option value="Feature">Story</option>
|
||||||
|
<option value="Improvement">Task</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="feedback-title">Title</label>
|
||||||
|
<input type="text" id="feedback-title" placeholder="Brief description">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="feedback-description">Description</label>
|
||||||
|
<textarea id="feedback-description" placeholder="Detailed description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Attachments</label>
|
||||||
|
<div class="attachment-buttons">
|
||||||
|
<button id="screenshot-btn" class="secondary-button">
|
||||||
|
<img src="assets/icons/screenshot.svg" alt="Screenshot">
|
||||||
|
Screenshot
|
||||||
|
</button>
|
||||||
|
<button id="record-btn" class="secondary-button">
|
||||||
|
<img src="assets/icons/record.svg" alt="Record">
|
||||||
|
Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="attachment-preview"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="submit-feedback" class="primary-button">Submit</button>
|
||||||
|
<button id="settings-btn" class="text-button">Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot Editor -->
|
||||||
|
<div id="screenshot-editor">
|
||||||
|
<div id="editor-toolbar"></div>
|
||||||
|
<canvas id="editor-canvas"></canvas>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button id="save-screenshot" class="primary-button">Save</button>
|
||||||
|
<button id="cancel-screenshot" class="secondary-button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="loading-overlay" class="hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="message">Processing...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification -->
|
||||||
|
<div id="notification" class="notification hidden">
|
||||||
|
<span class="message"></span>
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
21
scripts/generate-icons.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const sharp = require('sharp');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ICON_SIZES = [16, 48, 128];
|
||||||
|
const SOURCE_ICON = path.join(__dirname, '../assets/icon.png');
|
||||||
|
const DIST_DIR = path.join(__dirname, '../dist/assets/icons');
|
||||||
|
|
||||||
|
// Create dist directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(DIST_DIR)) {
|
||||||
|
fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate icons for each size
|
||||||
|
ICON_SIZES.forEach(size => {
|
||||||
|
sharp(SOURCE_ICON)
|
||||||
|
.resize(size, size)
|
||||||
|
.toFile(path.join(DIST_DIR, `icon${size}.png`))
|
||||||
|
.then(() => console.log(`Generated ${size}x${size} icon`))
|
||||||
|
.catch(err => console.error(`Error generating ${size}x${size} icon:`, err));
|
||||||
|
});
|
219
styles/annotator.css
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
.canvas-container {
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-group,
|
||||||
|
.color-group,
|
||||||
|
.size-group,
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn,
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover,
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn svg,
|
||||||
|
.action-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#color-picker {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#size-slider {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add separator between groups */
|
||||||
|
.tool-group:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: #ddd;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the done button differently */
|
||||||
|
#done-btn {
|
||||||
|
background: #4caf50;
|
||||||
|
border-color: #43a047;
|
||||||
|
}
|
||||||
|
|
||||||
|
#done-btn svg {
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#done-btn:hover {
|
||||||
|
background: #43a047;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
background: #F4F5F7;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#annotation-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(9, 30, 66, 0.13);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #DFE1E6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover {
|
||||||
|
background: #EBECF0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background: #DEEBFF;
|
||||||
|
border-color: #0052CC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #172B4D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-left: 1px solid #DFE1E6;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#color-picker {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stroke-width {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#done-btn {
|
||||||
|
background: #0052CC;
|
||||||
|
color: white;
|
||||||
|
border-color: #0052CC;
|
||||||
|
}
|
||||||
|
|
||||||
|
#done-btn:hover {
|
||||||
|
background: #0747A6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cancel-btn {
|
||||||
|
background: white;
|
||||||
|
color: #172B4D;
|
||||||
|
border-color: #DFE1E6;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cancel-btn:hover {
|
||||||
|
background: #EBECF0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
background: #F4F5F7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: crosshair;
|
||||||
|
box-shadow: 0 4px 8px rgba(9, 30, 66, 0.25);
|
||||||
|
}
|
287
styles/popup.css
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #0052CC;
|
||||||
|
--secondary-color: #172B4D;
|
||||||
|
--background-color: #FFFFFF;
|
||||||
|
--border-color: #DFE1E6;
|
||||||
|
--success-color: #36B37E;
|
||||||
|
--error-color: #FF5630;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 400px;
|
||||||
|
min-height: 300px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
color: #172B4D;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #DFE1E6;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #6B778C;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: #0052CC;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
background: #0747A6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
background: #EBECF0;
|
||||||
|
color: #172B4D;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover {
|
||||||
|
background: #DFE1E6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #0052CC;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachment buttons */
|
||||||
|
.attachment-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-buttons img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
#loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(9, 30, 66, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #FFFFFF;
|
||||||
|
border-top: 3px solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification */
|
||||||
|
#notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #00875A;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification.error {
|
||||||
|
background: #DE350B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form actions */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth Container */
|
||||||
|
#auth-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn:hover {
|
||||||
|
background-color: #F4F5F7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Capture Options */
|
||||||
|
.capture-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn:hover {
|
||||||
|
background-color: #F4F5F7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feedback Form */
|
||||||
|
#feedback-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input, textarea {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-btn:hover {
|
||||||
|
background-color: #0747A6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachment Preview */
|
||||||
|
#attachment-preview {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jira Fields */
|
||||||
|
.jira-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
63
webpack.config.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const CopyPlugin = require('copy-webpack-plugin');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: 'production',
|
||||||
|
entry: {
|
||||||
|
popup: './js/popup.js',
|
||||||
|
content: './content.js',
|
||||||
|
background: './background.js',
|
||||||
|
options: './js/options.js',
|
||||||
|
annotator: './js/annotator.js',
|
||||||
|
annotation: './js/annotation.js',
|
||||||
|
'screenshot-editor': './js/screenshot-editor.js',
|
||||||
|
'editor-init': './js/editor-init.js'
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
filename: '[name].js',
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
clean: true
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.js$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: {
|
||||||
|
loader: 'babel-loader',
|
||||||
|
options: {
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', {
|
||||||
|
targets: {
|
||||||
|
chrome: "58"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
],
|
||||||
|
plugins: ['@babel/plugin-transform-runtime']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CopyPlugin({
|
||||||
|
patterns: [
|
||||||
|
{ from: "manifest.json", to: "manifest.json" },
|
||||||
|
{ from: "*.html", to: "[name][ext]" },
|
||||||
|
{ from: "*.css", to: "[name][ext]" },
|
||||||
|
{ from: "styles/*.css", to: "styles/[name][ext]" },
|
||||||
|
{ from: "assets", to: "assets" }
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.js'],
|
||||||
|
fallback: {
|
||||||
|
"path": false,
|
||||||
|
"fs": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
minimize: false // Disable minification for debugging
|
||||||
|
}
|
||||||
|
};
|