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
|
||||
}
|
||||
};
|