initial commit

This commit is contained in:
Bhav Kushwaha 2025-01-23 23:04:49 +05:30
commit e1a56cea7a
62 changed files with 25182 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

93
README.md Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

5
assets/icons/icon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

BIN
assets/icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

BIN
assets/icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

4
assets/icons/record.svg Normal file
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

5
dist/assets/icons/icon.svg vendored Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
dist/assets/icons/icon128.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
dist/assets/icons/icon16-disabled.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

BIN
dist/assets/icons/icon16.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

BIN
dist/assets/icons/icon48-disabled.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

BIN
dist/assets/icons/icon48.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

4
dist/assets/icons/record.svg vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

107
dist/editor-init.js vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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">&times;</button>
</div>
</div>
<script type="module" src="popup.js"></script>
</body>
</html>

799
dist/popup.js vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View 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
View 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">&times;</button>
</div>
</div>
<script type="module" src="popup.js"></script>
</body>
</html>

21
scripts/generate-icons.js Normal file
View 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
View 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
View 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
View 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
}
};