diff --git a/.babelrc b/.babelrc index 86ef210..982043a 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,3 @@ { - "presets": ["@babel/preset-env", "@babel/preset-react"] -} + "presets": ["@babel/preset-react"] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c43c855..710752d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,35 @@ dist/ node_modules/ yarn.lock -package-lock.json \ No newline at end of file +package-lock.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env +.venv +venv + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +__pycache__ +__pycache__/ \ No newline at end of file diff --git a/AI/app.py b/AI/app.py index c5f2471..b3f3959 100644 --- a/AI/app.py +++ b/AI/app.py @@ -1,103 +1,145 @@ -from typing import Any -import logging -from flask import Flask, request, jsonify, current_app, send_from_directory -from flask_cors import CORS -from llmproxy import generate -import sys -from pymongo import MongoClient -import bcrypt +from __future__ import annotations + +import sys, json, logging, requests +from typing import List, Dict, Any, Union from dotenv import load_dotenv +from flask import Flask, request, jsonify, current_app +from flask_cors import CORS import os -sys.stdout.reconfigure(encoding='utf-8') - -app = Flask(__name__, static_folder='public', static_url_path='') -CORS(app, supports_credentials=True, origins=["https://localhost:5241, https://localhost:4000", "https://localhost:3000"]) - -@app.route('/api/document', methods=['POST']) -def respond_document(): - data = request.get_json() - document = data.get("attachment", "").strip() +load_dotenv() +api_key = os.getenv('api_key') +end_point = os.getenv('end_point') - if not document: - return jsonify({"response": "Please provide a document"}), 400 +print(f"API Key: {api_key}") +print(f"Database URL: {end_point}") - prompt = f"""""" - - - content = "" +sys.stdout.reconfigure(encoding='utf-8') +app = Flask(__name__, static_folder='public', static_url_path='') +CORS(app, supports_credentials=True, origins=[ + "https://localhost:5241", + "https://localhost:4000", + "https://localhost:3000" +]) + +log = logging.getLogger("pdf_logger") + +def _safe_get_json(raw: Union[Dict[str, Any], str], key: str) -> dict: + payload = raw.get(key, "") if isinstance(raw, dict) else "" + if isinstance(payload, dict): + return payload try: - final_response = generate( - model='4o-mini', - system=prompt, - query=content, - temperature=0.0, - lastk=0, - session_id='rew' - ) - + cleaned = payload.strip().removeprefix("```json").removesuffix("```").strip() + return json.loads(cleaned) except Exception as e: - current_app.logger.error("Error in generate: %s", str(e)) - return jsonify({"response": "Internal error during extraction."}), 500 - - return jsonify({"final_response": final_response}) - -@app.route('/api/image', methods=['POST']) -def respond_image(): - data = request.get_json() - - document = data.get("attachment", "").strip() - - if not document: - return jsonify({"response": "Please provide an image"}), 400 + current_app.logger.error("Failed JSON parse: %s", e) + raise ValueError("Invalid JSON in LLM output") + +def _llm_generate(md_text: str, prompt: str) -> dict: + raw = generate( + model="4o-mini", + system=prompt, + query=md_text[:8000], # truncate for cost/speed safety + temperature=0.3, + lastk=0, + session_id="ai-question-gen", + ) + + return _safe_get_json(raw, "response") + +def _cards_from_text( + text: str, questionPrompt: str, answerPrompt: str +) -> List[Dict[str, str]]: + questions = _llm_generate(text, questionPrompt).get("questions", []) + print(f"questions: {questions}") + + cards = [{"question": q, "answer": ""} for q in questions] + + user_parts = [] + user_parts.append("### CONTEXT\n" + text[:8000]) + user_parts.append("### CONTEXT END\n" + "### QUESTIONS\n".join(f"{i+1}. {q}" for i, q in enumerate(questions))) + query = "\n\n".join(user_parts) + + answers = _llm_generate(query, answerPrompt).get("answers", []) + + print(f"answers: {answers}") + + for i, a in enumerate(answers): + cards[i]["answer"] = a.strip() + + return cards + +@app.route("/api/document", methods=["POST"]) +def respond_document(): + data = request.get_json(force=True, silent=True) or {} + raw_text = str(data.get("text", "")).strip() + questionPrompt = str(data.get("questionPrompt", "")) + answerPrompt = str(data.get("answerPrompt", "")) - prompt = f"""""" + # 详细的调试日志 + current_app.logger.info(f"=== API Request Debug ===") + current_app.logger.info(f"Raw data received: {data}") + current_app.logger.info(f"Question Prompt: {questionPrompt}") + current_app.logger.info(f"answerPrompt: {answerPrompt}") - content = "" + if not raw_text: + return jsonify({"response": "Please provide non-empty 'text'."}), 400 try: - final_response = generate( - model='4o-mini', - system=prompt, - query=content, - temperature=0.0, - lastk=0, - session_id='rew' - ) - + cards = _cards_from_text(raw_text, questionPrompt, answerPrompt) + current_app.logger.info(f"Final cards generated: {len(cards)}") + current_app.logger.info(f"Card questions: {[card.get('question', '') for card in cards]}") except Exception as e: - current_app.logger.error("Error in generate: %s", str(e)) - return jsonify({"response": "Internal error during extraction."}), 500 - - return jsonify({"final_response": final_response}) - - -@app.route('/api/text', methods=['POST']) -def respond_text(): - data = request.get_json() - - text = data.get("text", "").strip() - - if not text: - return jsonify({"response": "Please provide text"}), 400 - - prompt = f"""""" - - content = "" + current_app.logger.error("Error in /api/document: %s", e) + return jsonify({"response": "Failed to generate flash cards."}), 500 + + return jsonify({"cards": cards}) + +def generate( + model: str, + system: str, + query: str, + temperature: float | None = None, + lastk: int | None = None, + session_id: str | None = None, + rag_threshold: float | None = 0.5, + rag_usage: bool | None = False, + rag_k: int | None = 0 +): + headers = { + 'x-api-key': api_key + } + + request = { + 'model': model, + 'system': system, + 'query': query, + 'temperature': temperature, + 'lastk': lastk, + 'session_id': session_id, + 'rag_threshold': rag_threshold, + 'rag_usage': rag_usage, + 'rag_k': rag_k + } + + print(f"request: {request}") + + msg = None try: - final_response = generate( - model='4o-mini', - system=prompt, - query=content, - temperature=0.0, - lastk=0, - session_id='rew' - ) - - except Exception as e: - current_app.logger.error("Error in generate : %s", str(e)) - return jsonify({"response": "Internal error during extraction."}), 500 - - return jsonify({"final_response": final_response}) \ No newline at end of file + response = requests.post(end_point, headers=headers, json=request) + + print(f"response: {response}") + + if response.status_code == 200: + res = json.loads(response.text) + msg = {'response': res['result'], 'rag_context': res['rag_context']} + else: + msg = f"Error: Received response code {response.status_code}" + except requests.exceptions.RequestException as e: + msg = f"An error occurred: {e}" + return msg + +if __name__ == "__main__": + app.run(ssl_context=("dev.crt", "dev.key"), host="127.0.0.1", port=5001) \ No newline at end of file diff --git a/AI/app_archeive.py b/AI/app_archeive.py new file mode 100644 index 0000000..a63920d --- /dev/null +++ b/AI/app_archeive.py @@ -0,0 +1,205 @@ +import sys, json, re, logging +from typing import List, Dict, Any, Union + +from flask import Flask, request, jsonify, current_app +from flask_cors import CORS +from llmproxy import generate + +sys.stdout.reconfigure(encoding='utf-8') + +app = Flask(__name__, static_folder='public', static_url_path='') +CORS(app, supports_credentials=True, origins=[ + "https://localhost:5241", + "https://localhost:4000", + "https://localhost:3000" +]) + +import logging +log = logging.getLogger("pdf_logger") + +def _safe_get_json(raw: Union[Dict[str, Any], str], key: str) -> dict: + """ + raw - dict returned by llmproxy.generate() + key - the field that *should* contain JSON or a JSON-ish string + + Returns a **dict** (never a str). Raises if it can't be parsed. + """ + if isinstance(raw, str): + current_app.logger.error("Raw response is string, not dict: %r", raw) + raise ValueError("LLM output is not valid JSON") + + payload = raw.get(key, "") + + if isinstance(payload, dict): + return payload + + if not isinstance(payload, str) or not payload.strip(): + current_app.logger.error("Empty/invalid payload for key '%s': %r", key, payload) + raise ValueError("LLM output is not valid JSON") + + cleaned = re.sub(r"^\s*```[^\\n]*\n|\n```$", "", payload.strip(), flags=re.MULTILINE) + + try: + return json.loads(cleaned) + except json.JSONDecodeError as e: + current_app.logger.error("JSON decode failed: %s\nRaw payload: %r", e, payload) + raise ValueError("LLM output is not valid JSON") + + +def _llm_get_questions(md_text: str, n: int, question_length: str) -> List[str]: + length_instructions = { + "Small": "Keep questions very short and concise (2-3s words maximum, like 'What are protons?').", + "Medium": "Keep questions moderately detailed but clear (around 7 words, like 'How do protons and electrons interact in an atom?').", + "Large": "Questions can be more detailed and comprehensive (around 12 words, like 'What is the relationship between protons, neutrons, and electrons in determining the properties of different elements?')." + } + + length_instruction = length_instructions.get(question_length, length_instructions["Medium"]) + + sys_prompt = f""" + You are an education expert. + From the given markdown context, extract EXACTLY {n} important key terms that + a general audience should learn. ONLY when absolutely necessary, + if the concept is too complex for just generating a term, you may phrase it + as a question. For example, prefer "Protons" over "What are protons?" + + {length_instruction} + + **Respond with STRICT JSON only – NO markdown, NO code fences, NO extra keys** + {{ "questions": ["Q1", "Q2", ...] }} + + IMPORTANT: You must return exactly {n} questions unless the content is insufficient. + """ + + current_app.logger.info(f"System prompt sent to AI: {sys_prompt}") + + raw = generate( + model="4o-mini", + system=sys_prompt, + query=md_text[:8000], # truncate for cost/speed safety + temperature=0.3, + lastk=0, + session_id="ai-question-gen", + ) + + questions_dict = _safe_get_json(raw, "response") + questions = questions_dict.get("questions", []) + current_app.logger.info(f"AI returned {len(questions)} questions: {questions}") + return questions + +def _complete_cards( + cards: List[Dict[str, str]], + context_md: str = "", + model: str = "4o-mini", + answer_length: str = "Medium", +) -> None: + unanswered_idx = [i for i, c in enumerate(cards) if not str(c.get("answer", "")).strip()] + if not unanswered_idx: + return + + qs = [cards[i]["question"].strip() for i in unanswered_idx] + + + length_instructions = { + "Small": "Keep answers very brief and to the point (4-5 words maximum, like 'Protons are positive particles.').", + "Medium": "Provide moderately detailed answers (15-20 words, like 'Protons are positively charged particles found in the nucleus of atoms.').", + "Large": "Give comprehensive answers with examples and explanations (around 30 words, like 'Protons are positively charged subatomic particles found in the nucleus of atoms, and they determine the atomic number of an element.')." + } + + length_instruction = length_instructions.get(answer_length, length_instructions["Medium"]) + + sys_prompt = f""" + You are an expert study-card generator. + Write every answer so it is appropriate for a general audience (choose vocabulary, length, and examples accordingly). + {length_instruction} + + For each entry, provide a concise, accurate answer. + • If the entry is a single word, give a short, precise definition. + • If the entry is a full question, answer it directly. + + Do **not** repeat the entry inside the answer itself. + + Return STRICT JSON of the form: + {{ "answers": ["", "", ...] }} (no markdown). + """ + + user_parts = [] + if context_md: + user_parts.append("### CONTEXT\n" + context_md[:8000]) + user_parts.append("\n" + "\n".join(f"{i+1}. {q}" for i, q in enumerate(qs))) + user_prompt = "\n\n".join(user_parts) + + raw = generate( + model=model, + system=sys_prompt, + query=user_prompt, + temperature=0.0, + lastk=0, + session_id="ai-card-complete", + ) + + answers = _safe_get_json(raw, "response").get("answers", []) + + if len(answers) != len(unanswered_idx): + raise ValueError("Answer count mismatch") + + for idx, ans in zip(unanswered_idx, answers): + cards[idx]["answer"] = ans.strip() + +def _cards_from_text(text: str, total_card_number: int = 4, question_length: str = "Medium", answer_length: str = "Medium") -> List[Dict[str, str]]: + questions = _llm_get_questions(text, n=total_card_number, question_length=question_length) + cards = [{"question": q, "answer": ""} for q in questions] + + _complete_cards(cards, text, answer_length=answer_length) + return cards + +@app.route("/api/document", methods=["POST"]) +def respond_document(): + data = request.get_json(force=True, silent=True) or {} + raw_text = str(data.get("text", "")).strip() + total_card_number = int(data.get("totalCardNumber", 4)) + question_length = str(data.get("questionLength", "Medium")) + answer_length = str(data.get("answerLength", "Medium")) + + # 详细的调试日志 + current_app.logger.info(f"=== API Request Debug ===") + current_app.logger.info(f"Raw data received: {data}") + current_app.logger.info(f"totalCardNumber from request: {data.get('totalCardNumber')}") + current_app.logger.info(f"Parsed total_card_number: {total_card_number}") + current_app.logger.info(f"questionLength: {question_length}") + current_app.logger.info(f"answerLength: {answer_length}") + + if not raw_text: + return jsonify({"response": "Please provide non-empty 'text'."}), 400 + + try: + cards = _cards_from_text(raw_text,total_card_number, question_length, answer_length) + current_app.logger.info(f"Final cards generated: {len(cards)}") + current_app.logger.info(f"Card questions: {[card.get('question', '') for card in cards]}") + except Exception as e: + current_app.logger.error("Error in /api/document: %s", e) + return jsonify({"response": "Failed to generate flash cards."}), 500 + + return jsonify({"cards": cards}) + +@app.route("/api/text", methods=["POST"]) +def respond_text(): + data = request.get_json(force=True, silent=True) or {} + cards = data.get("cards") + + if not isinstance(cards, list) or not cards: + return jsonify({"response": "Please provide a non-empty 'cards' array."}), 400 + + unanswered_idx = [i for i, c in enumerate(cards) if not str(c.get("answer", "")).strip()] + if not unanswered_idx: + return jsonify({"cards": cards}) + + try: + _complete_cards(cards) + except Exception as e: + current_app.logger.error("Error in /api/text generate: %s", e) + return jsonify({"response": "Internal error during answer generation."}), 500 + + return jsonify({"cards": cards}) + +if __name__ == "__main__": + app.run(ssl_context=("dev.crt", "dev.key"), host="127.0.0.1", port=5001) \ No newline at end of file diff --git a/AI/dev.crt b/AI/dev.crt new file mode 100644 index 0000000..f6620c6 --- /dev/null +++ b/AI/dev.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/AI/dev.key b/AI/dev.key new file mode 100644 index 0000000..c589354 --- /dev/null +++ b/AI/dev.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +ZQ4hXrAKa+7MUU3sruS+XtkbxCpU +-----END PRIVATE KEY----- diff --git a/AI/example_gen.py b/AI/example_gen.py new file mode 100644 index 0000000..a9c9f60 --- /dev/null +++ b/AI/example_gen.py @@ -0,0 +1,13 @@ +from llmproxy import generate + +if __name__ == '__main__': + response = generate( + model='4o-mini', + system="""Return 'Me!'""", + query=""""Who is the best AI?""", + temperature=0.0, + lastk=0, + session_id='GenericSession' + ) + + print(response) \ No newline at end of file diff --git a/AI/llmproxy.py b/AI/llmproxy.py new file mode 100644 index 0000000..35e628c --- /dev/null +++ b/AI/llmproxy.py @@ -0,0 +1,119 @@ +from __future__ import annotations +import json +import requests +from dotenv import load_dotenv +import os + +load_dotenv() +api_key = os.getenv('api_key') +end_point = os.getenv('end_point') + +print(f"API Key: {api_key}") +print(f"Database URL: {end_point}") + + +def generate( + model: str, + system: str, + query: str, + temperature: float | None = None, + lastk: int | None = None, + session_id: str | None = None, + rag_threshold: float | None = 0.5, + rag_usage: bool | None = False, + rag_k: int | None = 0 +): + headers = { + 'x-api-key': api_key + } + + request = { + 'model': model, + 'system': system, + 'query': query, + 'temperature': temperature, + 'lastk': lastk, + 'session_id': session_id, + 'rag_threshold': rag_threshold, + 'rag_usage': rag_usage, + 'rag_k': rag_k + } + + print(f"request: {request}") + + msg = None + + try: + response = requests.post(end_point, headers=headers, json=request) + + print(f"response: {response}") + + if response.status_code == 200: + res = json.loads(response.text) + msg = {'response': res['result'], 'rag_context': res['rag_context']} + else: + msg = f"Error: Received response code {response.status_code}" + except requests.exceptions.RequestException as e: + msg = f"An error occurred: {e}" + return msg + + +def upload(multipart_form_data): + headers = { + 'x-api-key': api_key + } + + msg = None + try: + response = requests.post(end_point, headers=headers, files=multipart_form_data) + + if response.status_code == 200: + msg = "Successfully uploaded. It may take a short while for the document to be added to your context" + else: + msg = f"Error: Received response code {response.status_code}" + except requests.exceptions.RequestException as e: + msg = f"An error occurred: {e}" + + return msg + + +def pdf_upload( + path: str, + strategy: str | None = None, + description: str | None = None, + session_id: str | None = None +): + params = { + 'description': description, + 'session_id': session_id, + 'strategy': strategy + } + + multipart_form_data = { + 'params': (None, json.dumps(params), 'application/json'), + 'file': (None, open(path, 'rb'), "application/pdf") + } + + response = upload(multipart_form_data) + return response + + +def text_upload( + text: str, + strategy: str | None = None, + description: str | None = None, + session_id: str | None = None +): + params = { + 'description': description, + 'session_id': session_id, + 'strategy': strategy + } + + multipart_form_data = { + 'params': (None, json.dumps(params), 'application/json'), + 'text': (None, text, "application/text") + } + + response = upload(multipart_form_data) + return response diff --git a/FLASHCARD_USAGE.md b/FLASHCARD_USAGE.md new file mode 100644 index 0000000..836228e --- /dev/null +++ b/FLASHCARD_USAGE.md @@ -0,0 +1,47 @@ +# FlashCard 插入功能使用指南 + +## 功能说明 + +该功能允许您在Adobe Express中创建和插入闪卡(FlashCard)。每张闪卡包含: +- 一个白色背景的矩形框 +- 居中显示的问题文本 (Q: ...) +- 居中显示的答案文本 (A: ...) + +## 使用步骤 + +1. **手动输入内容**: + - 在"Manual Enter"标签页中 + - 填写"Question"和"Answer"文本框 + - 可以点击"Add Card +"按钮添加更多卡片 + +2. **插入闪卡**: + - 确保至少有一张卡片填写了问题或答案 + - 点击"Insert Flash Cards"按钮 + - 闪卡将自动插入到Adobe Express画布中 + +## 闪卡规格 + +- **尺寸**: 400px × 250px +- **背景**: 白色 (#FFFFFF) +- **边框**: 灰色 (#CCCCCC),2px宽度 +- **间距**: 卡片之间垂直间距50px +- **位置**: 从左上角(50, 50)开始垂直排列 + +## 文本格式 + +- **问题**: 以"Q: "开头,显示在卡片上半部分 +- **答案**: 以"A: "开头,显示在卡片下半部分 +- **字体**: 使用系统默认字体 + +## 注意事项 + +1. 空白的卡片(问题和答案都为空)将被自动过滤,不会插入 +2. 只填写问题或只填写答案的卡片也会正常插入 +3. 闪卡将按照输入顺序垂直排列在画布上 +4. 确保Adobe Express插件已正确加载 + +## 技术实现 + +- 使用Adobe Express Document API +- 通过Document Sandbox创建几何形状和文本元素 +- 支持响应式布局和样式设置 \ No newline at end of file diff --git a/README.md b/README.md index e28bea6..ec84a9a 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,11 @@ This project has been created with _@adobe/create-ccweb-add-on_. As an example, 1. To install the dependencies, run `npm install`. 2. To build the application, run `npm run build`. 3. To start the application, run `npm run start`. + +## To start the AI components + +` +cd AI +flask run --cert=dev.crt --key=dev.key +` # FlashCard diff --git a/TEMPLATE_USAGE.md b/TEMPLATE_USAGE.md new file mode 100644 index 0000000..4a2a721 --- /dev/null +++ b/TEMPLATE_USAGE.md @@ -0,0 +1,77 @@ +# 模板参数使用指南 + +## 概述 + +现在 `applyTemplate1` 函数支持通过参数来自定义模板样式。你可以通过 `settings` 对象传递各种模板参数来控制卡片的外观。 + +## 可用的模板参数 + +### 基本参数 + +- `template`: 模板名称,设置为 `"template1"` 来启用模板,设置为 `"none"` 来禁用模板 + +### 样式参数 + +- `templateBackgroundColor`: 背景颜色 (十六进制颜色值,如 `"#FFDCDC"`) +- `templateBorderColor`: 边框颜色 (十六进制颜色值,如 `"#CCCCCC"`) +- `templateBorderWidth`: 边框宽度 (数字,如 `2`) +- `templateCornerRadius`: 圆角半径 (数字,如 `12`) +- `templateFontSize`: 字体大小 (数字,如 `16`) +- `templateTextColor`: 文字颜色 (十六进制颜色值,如 `"#000000"`) + +## 使用示例 + +### 基本使用 + +```javascript +const settings = { + template: "template1", + cardSize: "medium", + cardColor: "#FFFFFF" +}; +``` + +### 自定义样式 + +```javascript +const settings = { + template: "template1", + cardSize: "large", + cardColor: "#FFFFFF", + // 模板参数 + templateBackgroundColor: "#E8F4FD", + templateBorderColor: "#2196F3", + templateBorderWidth: 3, + templateCornerRadius: 15, + templateFontSize: 18, + templateTextColor: "#1976D2" +}; +``` + +### 禁用模板 + +```javascript +const settings = { + template: "none", + cardSize: "medium", + cardColor: "#FFFFFF" +}; +``` + +## 默认值 + +如果不指定模板参数,将使用以下默认值: + +- `backgroundColor`: 使用预定义的颜色数组 (按索引循环) +- `borderColor`: `"#CCCCCC"` +- `borderWidth`: `2` +- `cornerRadius`: `12` +- `fontSize`: 基于卡片大小的响应式字体大小 +- `textColor`: `"#000000"` + +## 注意事项 + +1. 所有颜色值必须是有效的十六进制颜色格式 +2. 数字参数必须是有效的数字 +3. 如果参数值无效,将使用默认值 +4. 模板功能是可选的,可以通过设置 `template: "none"` 来禁用 diff --git a/index.html b/index.html new file mode 100644 index 0000000..2539718 --- /dev/null +++ b/index.html @@ -0,0 +1,36 @@ + + + + + + + + + + +
+ + + diff --git a/src/components/App.css b/index.js similarity index 54% rename from src/components/App.css rename to index.js index 21a6b87..14d2087 100644 --- a/src/components/App.css +++ b/index.js @@ -10,23 +10,17 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -body { - margin: 0; - padding: 0; -} - -.container { - padding: 0 24px; - display: flex; - flex-direction: column; - justify-content: center; -} - -.hidden { - display: none; -} - -.visible { - display: flex; - flex-direction: column; -} +import AddOnSdk from "https://new.express.adobe.com/static/add-on-sdk/sdk.js"; +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./components/App.js"; +AddOnSdk.ready.then(async () => { + const { + runtime + } = AddOnSdk.instance; + const sandboxProxy = await runtime.apiProxy("documentSandbox"); + const root = createRoot(document.getElementById("root")); + root.render(/*#__PURE__*/React.createElement(App, { + sandboxProxy: sandboxProxy + })); +}); \ No newline at end of file diff --git a/lol.py b/lol.py deleted file mode 100644 index 5bb3861..0000000 --- a/lol.py +++ /dev/null @@ -1 +0,0 @@ -print(f"request:") \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a6302c8 --- /dev/null +++ b/manifest.json @@ -0,0 +1,22 @@ +{ + "testId": "AI-flashcard-maker", + "name": "AI Flashcard Maker", + "version": "1.0.0", + "manifestVersion": 2, + "requirements": { + "apps": [ + { + "name": "Express", + "apiVersion": 1 + } + ] + }, + "entryPoints": [ + { + "type": "panel", + "id": "panel1", + "main": "index.html", + "documentSandbox": "code.js" + } + ] +} diff --git a/package.json b/package.json index c012f97..db8ed70 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,15 @@ "Dialog" ], "scripts": { - "clean": "ccweb-add-on-scripts clean", - "build": "ccweb-add-on-scripts build --use webpack", - "start": "ccweb-add-on-scripts start --use webpack" + "replace-imports": "find src -type f -name '*.js' -exec sed -i '' 's/\\.jsx/\\.js/g' {} +", + "clean": "find src -type f -name '*.js' -exec sh -c 'for f; do jsx_file=\"${f%.js}.jsx\"; [ -f \"$jsx_file\" ] && rm \"$f\"; done' _ {} +", + "build": "babel src --out-dir src --extensions .jsx && npm run replace-imports && npm run clean", + "start": "ccweb-add-on-scripts start --use webpack", + "zip": "cd dist && zip -r ../dist.zip *", + "package": "babel src --out-dir src --extensions .jsx && npm run replace-imports && ccweb-add-on-scripts package && npm run clean" }, "dependencies": { + "@babel/cli": "^7.28.0", "@spectrum-web-components/theme": "0.37.0", "@swc-react/button": "0.37.0", "@swc-react/checkbox": "0.37.0", @@ -23,15 +27,17 @@ "@swc-react/radio": "0.37.0", "@swc-react/textfield": "0.37.0", "@swc-react/theme": "0.37.0", - "papaparse": "^5.5.3", + "jszip": "^3.10.1", + "pdfjs-dist": "^5.3.31", "react": "18.2.0", - "react-dom": "18.2.0" + "react-dom": "18.2.0", + "tesseract.js": "^6.0.1" }, "devDependencies": { "@adobe/ccweb-add-on-scripts": "^2.0.0", "@babel/core": "7.18.13", "@babel/preset-env": "7.18.10", - "@babel/preset-react": "7.18.6", + "@babel/preset-react": "^7.18.6", "babel-loader": "8.2.5", "babel-preset-react-app": "10.0.1", "copy-webpack-plugin": "11.0.0", diff --git a/src/assets/icons/AI.svg b/src/assets/icons/AI.svg new file mode 100644 index 0000000..b412325 --- /dev/null +++ b/src/assets/icons/AI.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/fullscreen.svg b/src/assets/icons/fullscreen.svg index 23ecf49..eb72ce9 100644 --- a/src/assets/icons/fullscreen.svg +++ b/src/assets/icons/fullscreen.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/icons/trash.svg b/src/assets/icons/trash.svg new file mode 100644 index 0000000..21215e2 --- /dev/null +++ b/src/assets/icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/templates/template1.png b/src/assets/templates/template1.png new file mode 100644 index 0000000..04bc905 Binary files /dev/null and b/src/assets/templates/template1.png differ diff --git a/src/code.js b/src/code.js new file mode 100644 index 0000000..08e0522 --- /dev/null +++ b/src/code.js @@ -0,0 +1,889 @@ +import addOnSandboxSdk from "add-on-sdk-document-sandbox"; +import { editor, colorUtils } from "express-document-sdk"; + +const { runtime } = addOnSandboxSdk.instance; + + +const MAX_CARDS_PER_ARTBOARD = 4; + +function getCardsPerPageLimit() { + return MAX_CARDS_PER_ARTBOARD; +} + +// TODO for lena : same side/two side should be handled differently +function getOrCreateArtboard(artboardIndex) { + const pages = editor.documentRoot.pages; + let page = pages.item(artboardIndex); + let artboard = page.artboards.first; + return { page, artboard }; +} + + + + +function getPageLayoutConfig(cardsOnThisPage, layout, canvasWidth, canvasHeight, typeKey) { + return getOptimalLayout(cardsOnThisPage, layout, canvasWidth, canvasHeight, typeKey); +} + +// Automatic text wrapping function +function wrapText(text, maxWidth, fontSize) { + if (!text || !text.trim()) return ""; + + // Estimate character width based on font size + const charWidthRatio = 0.8; + const avgCharWidth = fontSize * charWidthRatio; + const calculatedMaxChars = Math.floor(maxWidth / avgCharWidth); + + // Set reasonable line wrapping limits - avoid lines that are too long for readability + const idealMaxChars = Math.min(calculatedMaxChars, 50); // Maximum 50 characters per line + const maxCharsPerLine = Math.max(20, idealMaxChars); // Minimum 20 characters per line + + // If text is short, no wrapping needed + if (text.length <= maxCharsPerLine) { + return text; + } + + const words = text.split(/\s+/); + const lines = []; + let currentLine = ""; + + for (const word of words) { + // Check if current line plus new word exceeds limit + const testLine = currentLine ? `${currentLine} ${word}` : word; + + if (testLine.length <= maxCharsPerLine) { + currentLine = testLine; + } else { + // If current line is not empty, save current line first + if (currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + // If word is too long, force split + if (word.length > maxCharsPerLine) { + let remainingWord = word; + while (remainingWord.length > maxCharsPerLine) { + lines.push(remainingWord.substring(0, maxCharsPerLine)); + remainingWord = remainingWord.substring(maxCharsPerLine); + } + currentLine = remainingWord; + } else { + currentLine = word; + } + } + } + } + + // Add the last line + if (currentLine) { + lines.push(currentLine); + } + + return lines.join("\n"); +} + +// Create text with automatic wrapping support +function createFlashcardText(text, maxWidth, fontSize, centerX, centerY) { + try { + // Use wrapText for intelligent text wrapping + const wrappedText = wrapText(text, maxWidth, fontSize); + const textElement = editor.createText(wrappedText); + + // Set basic styles + textElement.fullContent.applyCharacterStyles({ fontSize: fontSize }, { start: 0, length: wrappedText.length }); + + // Set position + textElement.setPositionInParent({ x: centerX, y: centerY }, { x: 0.5, y: 0.5 }); + + return textElement; + } catch (error) { + console.error("Text creation failed:", error); + return null; + } +} + +// Create card data based on type +function createCardData(cards, typeKey) { + if (typeKey === "qa_two_side") { + // For two-side type: create separate cards for questions and answers + const cardData = []; + cards.forEach((card, index) => { + // Question card + cardData.push({ + type: "question", + content: card.question || "", + question: card.question || "", + answer: card.answer || "", + index: index, + label: `Q${index + 1}`, + isEmpty: !card.question || !card.question.trim() + }); + // Answer card + cardData.push({ + type: "answer", + content: card.answer || "", + question: card.question || "", + answer: card.answer || "", + index: index, + label: `A${index + 1}`, + isEmpty: !card.answer || !card.answer.trim() + }); + }); + return cardData; + } else { + // For same-side type: keep original card structure + return cards.map((card, index) => ({ + type: "qa_combined", + question: card.question, + answer: card.answer, + index: index + })); + } +} + +// Layout selection +function getOptimalLayout(qaCount, layoutType, canvasWidth, canvasHeight, typeKey) { + if (layoutType === "list") { + if (typeKey === "qa_two_side") { + return { rows: qaCount * 2, cols: 1, type: "list_paired", qaCount: qaCount }; + } else { + return { rows: qaCount, cols: 1, type: "list", qaCount: qaCount }; + } + } + + // Grid layout + if (typeKey === "qa_two_side") { + if (qaCount === 1) return { rows: 2, cols: 1, type: "grid_paired", qaCount: qaCount }; + if (qaCount === 2) return { rows: 2, cols: 2, type: "grid_paired", qaCount: qaCount }; + if (qaCount === 3) return { rows: 2, cols: 3, type: "grid_paired", qaCount: qaCount }; + if (qaCount === 4) return { rows: 4, cols: 2, type: "grid_paired", qaCount: qaCount }; + if (qaCount <= 6) return { rows: 4, cols: 3, type: "grid_paired", qaCount: qaCount }; + if (qaCount <= 9) return { rows: 6, cols: 3, type: "grid_paired", qaCount: qaCount }; + + const cols = Math.min(4, Math.ceil(Math.sqrt(qaCount))); + const rows = Math.ceil(qaCount / cols) * 2; + return { rows, cols, type: "grid_paired", qaCount: qaCount }; + } else { + // For qa_same_side, keep original 2x2 grid logic for compatibility + if (qaCount <= 4) { + const positions = [ + { row: 0, col: 0 }, + { row: 0, col: 1 }, + { row: 1, col: 0 }, + { row: 1, col: 1 } + ]; + return { rows: 2, cols: 2, type: "grid", qaCount: qaCount, positions }; + } + + // For more than 4 cards, use flexible grid + if (qaCount <= 6) return { rows: 2, cols: 3, type: "grid", qaCount: qaCount }; + if (qaCount <= 9) return { rows: 3, cols: 3, type: "grid", qaCount: qaCount }; + if (qaCount <= 12) return { rows: 3, cols: 4, type: "grid", qaCount: qaCount }; + + const cols = Math.min(4, Math.ceil(Math.sqrt(qaCount))); + const rows = Math.ceil(qaCount / cols); + return { rows, cols, type: "grid", qaCount: qaCount }; + } +} + +// Calculate card size +function calculateCardSize(layoutConfig, canvasWidth, canvasHeight, sizePercentage, orientation = "horizontal") { + let padding, gap; + + if (layoutConfig.type.includes("list")) { + padding = 30; + gap = 15; + } else { + padding = 50; // Keep original padding for grid + gap = 30; // Keep original gap for grid + } + + let baseWidth, baseHeight; + + if (layoutConfig.type.includes("list")) { + baseWidth = (canvasWidth - 2 * padding) * 0.9; + baseHeight = (canvasHeight - 2 * padding - (layoutConfig.rows - 1) * gap) / layoutConfig.rows; + } else { + // Keep original 2x2 grid calculation for compatibility + const availableWidth = canvasWidth - 2 * padding - gap; + const availableHeight = canvasHeight - 2 * padding - gap; + baseWidth = availableWidth / layoutConfig.cols; + baseHeight = availableHeight / layoutConfig.rows; + } + + const sizeMultiplier = sizePercentage / 100; + let cardWidth = Math.max(150, baseWidth * sizeMultiplier); // Keep original minimum + let cardHeight = Math.max(100, baseHeight * sizeMultiplier); // Keep original minimum + + // Swap width and height for vertical orientation + if (orientation === "vertical") { + [cardWidth, cardHeight] = [cardHeight, cardWidth]; + } + + return { cardWidth, cardHeight }; +} + +// Calculate card position +function calculateCardPosition(index, layoutConfig, cardWidth, cardHeight, canvasWidth, canvasHeight, cardData) { + const gap = layoutConfig.type.includes("list") ? 15 : 30; // Keep original gap for grid + + const totalGridWidth = layoutConfig.cols * cardWidth + (layoutConfig.cols - 1) * gap; + const totalGridHeight = layoutConfig.rows * cardHeight + (layoutConfig.rows - 1) * gap; + + const startX = (canvasWidth - totalGridWidth) / 2; + const startY = (canvasHeight - totalGridHeight) / 2; + + let row, col; + + if (layoutConfig.type === "grid_paired") { + const card = cardData[index]; + const pairIndex = card.index; + const isAnswer = card.type === "answer"; + + const pairCol = pairIndex % layoutConfig.cols; + const pairRowGroup = Math.floor(pairIndex / layoutConfig.cols); + + row = pairRowGroup * 2 + (isAnswer ? 1 : 0); + col = pairCol; + } else if (layoutConfig.type === "list_paired") { + const card = cardData[index]; + const pairIndex = card.index; + const isAnswer = card.type === "answer"; + + row = pairIndex * 2 + (isAnswer ? 1 : 0); + col = 0; + } else if (layoutConfig.positions) { + // Use original 2x2 positions for compatibility + const position = layoutConfig.positions[index] || { + row: Math.floor(index / layoutConfig.cols), + col: index % layoutConfig.cols + }; + row = position.row; + col = position.col; + } else { + // for cards on this page, use current page index + row = Math.floor(index / layoutConfig.cols); + col = index % layoutConfig.cols; + } + + return { + x: startX + col * (cardWidth + gap), + y: startY + row * (cardHeight + gap) + }; +} + +// Add this function near the top of the file, after imports +function applyCardCornerRadius(rect) { + rect.topLeftRadius = 30; + rect.topRightRadius = 30; + rect.bottomLeftRadius = 30; + rect.bottomRightRadius = 30; +} + +function start() { + runtime.exposeApi({ + createFlashcards: async (cards, settings = {}) => { + try { + console.log("Starting to create flashcards with settings:", settings); + + const sizePercentage = getCardSizePercentage(settings.cardSize || "medium"); + const cardColor = settings.cardColor || "#FFFFFF"; + const layout = settings.layout || "grid"; + const typeKey = settings.typeKey || "qa_same_side"; + const orientation = settings.orientation || "horizontal"; + + // Create card data based on type + const cardData = createCardData(cards, typeKey); + + // get card number limits + const cardsPerArtboard = getCardsPerPageLimit(); + + // caculate total artboards + const totalArtboards = Math.ceil(cardData.length / cardsPerArtboard); + const totalPages = Math.ceil(cardData.length / cardsPerArtboard); + + const pages = editor.documentRoot.pages; + const pagesLength = pages.length; + + if (pagesLength > 1) { + for (let i = pagesLength - 1; i >= 1; i--) { + pages.remove(pages.item(i)) + } + } + + const artboards = pages.first.artboards; + + // Step 1: Remove all artboards except the first one + while (artboards.length > 1) { + artboards.remove(artboards.last); // remove from end + } + + // Step 2: Clear all children from the first (remaining) artboard + artboards.first.children.clear(); + + // add all necessary pages + const pageWidth = artboards.first.width; + const pageHeight = artboards.first.height; + while (pages.length < totalPages) { + pages.addPage({ + height: pageHeight, + width: pageWidth + }); + } + + // group cards by artboard + for (let artboardIndex = 0; artboardIndex < totalArtboards; artboardIndex++) { + const startIndex = artboardIndex * cardsPerArtboard; + const endIndex = Math.min(startIndex + cardsPerArtboard, cardData.length); + const cardsOnThisArtboard = cardData.slice(startIndex, endIndex); + + // get the current artboard + const calculatedArtboardIndex = Math.floor(startIndex / cardsPerArtboard); + const { page, artboard } = getOrCreateArtboard(calculatedArtboardIndex); + const canvasWidth = artboard.width; + const canvasHeight = artboard.height; + + // caculates layout + const layoutConfig = getPageLayoutConfig(cardsOnThisArtboard.length, layout, canvasWidth, canvasHeight, typeKey); + + // caculates card size + const { cardWidth, cardHeight } = calculateCardSize( + layoutConfig, + canvasWidth, + canvasHeight, + sizePercentage, + orientation + ); + // Template key mapping + const templateMap = { + peachy_dawn: "template1", + template1: "template1", + blue_sky: "template2", + template2: "template2", + vivid_colors: "template4", + template4: "template4" + }; + const templateKey = templateMap[settings.template] || settings.template; + + // process cards on this artboard + cardsOnThisArtboard.forEach((card, artboardCardIndex) => { + // caculate global card index + const globalCardIndex = startIndex + artboardCardIndex; + + // caculate card position on this artboard + const position = calculateCardPosition( + artboardCardIndex, + layoutConfig, + cardWidth, + cardHeight, + canvasWidth, + canvasHeight, + cardsOnThisArtboard + ); + + const cardGroup = editor.createGroup(); + + // Build cardForTemplate to ensure question/answer fields exist + let cardForTemplate = { ...card }; + if (card.type === "question" && !cardForTemplate.question) { + cardForTemplate.question = card.content; + } + if (card.type === "answer" && !cardForTemplate.answer) { + cardForTemplate.answer = card.content; + } + + // Template branch: if template exists, use template colors only + if (templateKey && templateKey !== "none") { + let applyTemplate; + switch (templateKey) { + case "template1": + applyTemplate = applyTemplate1; + break; + case "template2": + applyTemplate = applyTemplate2; + break; + case "template4": + applyTemplate = applyTemplate4; + break; + default: + applyTemplate = applyTemplate1; + break; + } + console.log(`Applying template ${templateKey} - template will use its own colors`); + applyTemplate( + cardGroup, + { cardWidth, cardHeight }, + cardForTemplate, + { x: position.x, y: position.y }, + globalCardIndex + ); + } else { + // Default branch: use settings color and text wrapping + const cardRect = editor.createRectangle(); + cardRect.width = cardWidth; + cardRect.height = cardHeight; + applyCardCornerRadius(cardRect); + cardRect.translation = { x: 0, y: 0 }; + try { + const bgColor = colorUtils.fromHex(cardColor); + const bgFill = editor.makeColorFill(bgColor); + cardRect.fill = bgFill; + console.log(`Applied color: ${cardColor}`); + } catch (colorError) { + console.log("Invalid color, using default:", colorError); + const bgColor = colorUtils.fromHex("#FFFFFF"); + const bgFill = editor.makeColorFill(bgColor); + cardRect.fill = bgFill; + } + const strokeColor = colorUtils.fromHex("#CCCCCC"); + const stroke = editor.makeStroke({ + color: strokeColor, + width: 2 + }); + cardRect.stroke = stroke; + cardGroup.children.append(cardRect); + + // Add content based on card type + if (card.type === "question") { + // Question-only card (qa_two_side) + addQuestionOnlyContent(cardGroup, card, cardWidth, cardHeight); + } else if (card.type === "answer") { + // Answer-only card (qa_two_side) + addAnswerOnlyContent(cardGroup, card, cardWidth, cardHeight); + } else { + // Combined Q&A card (qa_same_side) - use original text wrapping logic + // Calculate text area parameters + const textPadding = Math.max(10, cardWidth * 0.05); + let maxTextWidth = cardWidth - textPadding * 2; + + // Dynamic font size calculation + const minCardDimension = Math.min(cardWidth, cardHeight); + let fontSize = Math.max(10, Math.min(minCardDimension / 60, 18)); + + // Smart text wrapping + const enableForceWrap = settings.forceWrap !== false; + if (enableForceWrap) { + const originalMaxTextWidth = maxTextWidth; + maxTextWidth = Math.min(maxTextWidth, fontSize * 30); + + if (maxTextWidth < originalMaxTextWidth * 0.7) { + const widthReductionFactor = maxTextWidth / originalMaxTextWidth; + fontSize = fontSize * Math.max(0.8, widthReductionFactor); + } + } + + console.log(`Font size: ${fontSize}, Max text width: ${maxTextWidth}`); + + // Create question text with wrapping + if (card.question && card.question.trim()) { + const questionText = createFlashcardText( + card.question, + maxTextWidth, + fontSize, + cardWidth / 2, + cardHeight / 6 + textPadding + ); + if (questionText) { + cardGroup.children.append(questionText); + console.log(`Added question text: ${card.question}`); + } + } + + // Create answer text with wrapping + const answerContent = + card.answer && card.answer.trim() ? card.answer : "【Answer to be added】"; + if (answerContent) { + const answerText = createFlashcardText( + answerContent, + maxTextWidth, + fontSize, + cardWidth / 2, + (cardHeight * 3) / 6 + textPadding + ); + if (answerText) { + cardGroup.children.append(answerText); + console.log(`Added answer text: ${answerContent}`); + } + } + } + } + + // Set flashcard group position + cardGroup.translation = { + x: position.x, + y: position.y + }; + artboard.children.append(cardGroup); + + + }); + + } + + } catch (error) { + console.error("Failed to insert flashcards:", error); + throw error; + } + }, + lol: async () => { + const totalPages = 3; + const pages = editor.documentRoot.pages; + const pagesLength = pages.length; + + if (pagesLength > 1) { + for (let i = pagesLength - 1; i >= 1; i--) { + pages.remove(pages.item(i)) + } + } + + const artboards = pages.first.artboards; + + // Step 1: Remove all artboards except the first one + while (artboards.length > 1) { + artboards.remove(artboards.last); // remove from end + } + + // Step 2: Clear all children from the first (remaining) artboard + artboards.first.children.clear(); + + // add all necessary pages + const pageWidth = artboards.first.width; + const pageHeight = artboards.first.height; + while (pages.length < totalPages) { + pages.addPage({ + height: pageHeight, + width: pageWidth + }); + } + } + }); +} + +// Add question-only content +function addQuestionOnlyContent(cardGroup, card, cardWidth, cardHeight) { + const fontSize = Math.max(10, Math.min(24, Math.min(cardWidth, cardHeight) / 15)); + + // Add label + if (card.label) { + const labelText = editor.createText(card.label); + labelText.setPositionInParent({ x: cardWidth / 2, y: cardHeight / 6 }, { x: 0.5, y: 0.5 }); + + try { + labelText.fullContent.applyCharacterStyles( + { fontSize: fontSize * 0.8, fontWeight: "bold" }, + { start: 0, length: card.label.length } + ); + } catch (styleError) { + console.log("Failed to set label text style:", styleError); + } + + cardGroup.children.append(labelText); + } + + // Add question content + let contentToShow = card.content && card.content.trim() ? card.content : "(Empty Question)"; + const questionText = editor.createText(contentToShow); + questionText.setPositionInParent({ x: cardWidth / 2, y: cardHeight / 2 }, { x: 0.5, y: 0.5 }); + + try { + const styles = card.isEmpty ? { fontSize: fontSize * 0.8, fontStyle: "italic" } : { fontSize: fontSize }; + questionText.fullContent.applyCharacterStyles(styles, { start: 0, length: contentToShow.length }); + } catch (styleError) { + console.log("Failed to set question text style:", styleError); + } + + cardGroup.children.append(questionText); +} + +// Add answer-only content +function addAnswerOnlyContent(cardGroup, card, cardWidth, cardHeight) { + const fontSize = Math.max(10, Math.min(24, Math.min(cardWidth, cardHeight) / 15)); + + // Add label + if (card.label) { + const labelText = editor.createText(card.label); + labelText.setPositionInParent({ x: cardWidth / 2, y: cardHeight / 6 }, { x: 0.5, y: 0.5 }); + + try { + labelText.fullContent.applyCharacterStyles( + { fontSize: fontSize * 0.8, fontWeight: "bold" }, + { start: 0, length: card.label.length } + ); + } catch (styleError) { + console.log("Failed to set label text style:", styleError); + } + + cardGroup.children.append(labelText); + } + + // Add answer content + let contentToShow = card.content && card.content.trim() ? card.content : "(Empty Answer)"; + const answerText = editor.createText(contentToShow); + answerText.setPositionInParent({ x: cardWidth / 2, y: cardHeight / 2 }, { x: 0.5, y: 0.5 }); + + try { + const styles = card.isEmpty ? { fontSize: fontSize * 0.8, fontStyle: "italic" } : { fontSize: fontSize }; + answerText.fullContent.applyCharacterStyles(styles, { start: 0, length: contentToShow.length }); + } catch (styleError) { + console.log("Failed to set answer text style:", styleError); + } + + cardGroup.children.append(answerText); +} + +function getCardSizePercentage(sizeKey) { + const sizes = { + xs: 50, + small: 65, + medium: 80, + large: 95, + xl: 110 + }; + return sizes[sizeKey] || 80; // Default 80% +} + +const TEMPLATE_COLORS = ["#FFDCDC", "#FFF2EB", "#FFE8CD", "#FFD6BA"]; +const TEMPLATE2_COLORS = ["#819A91", "#A7C1A8", "#D1D8BE", "#EEEFE0"]; +const TEMPLATE4_COLORS = ["#E5D9F2", "#F5EFFF", "#CDC1FF", "#A594F9"]; + +function applyTemplate1(cardGroup, { cardWidth, cardHeight }, card, position = { x: 0, y: 0 }, index = 0) { + // 1. 背景矩形,使用模板色 + const rect = editor.createRectangle(); + rect.width = cardWidth; + rect.height = cardHeight; + applyCardCornerRadius(rect); + // 在 qa_two_side 模式下,使用 card.index 来选择颜色,确保问答对使用相同颜色 + const colorIndex = card.type === "question" || card.type === "answer" ? card.index : index; + const colorHex = TEMPLATE_COLORS[colorIndex % TEMPLATE_COLORS.length]; + rect.fill = editor.makeColorFill(colorUtils.fromHex(colorHex)); + // 边框 + rect.stroke = editor.makeStroke({ color: colorUtils.fromHex("#CCCCCC"), width: 2 }); + cardGroup.children.append(rect); + + // 处理两种模式:qa_two_side 和 qa_same_side + if (card.type === "question" || card.type === "answer") { + // qa_two_side 模式 - 使用默认的处理函数 + if (card.type === "question") { + addQuestionOnlyContent(cardGroup, card, cardWidth, cardHeight); + } else { + addAnswerOnlyContent(cardGroup, card, cardWidth, cardHeight); + } + } else { + // qa_same_side 模式 + // Calculate text area parameters + const textPadding = Math.max(10, cardWidth * 0.05); + let maxTextWidth = cardWidth - textPadding * 2; + + // Dynamic font size calculation + const minCardDimension = Math.min(cardWidth, cardHeight); + let fontSize = Math.max(10, Math.min(minCardDimension / 60, 18)); + + // Smart text wrapping + const originalMaxTextWidth = maxTextWidth; + maxTextWidth = Math.min(maxTextWidth, fontSize * 30); + + if (maxTextWidth < originalMaxTextWidth * 0.7) { + const widthReductionFactor = maxTextWidth / originalMaxTextWidth; + fontSize = fontSize * Math.max(0.8, widthReductionFactor); + } + + // Create question text with wrapping + if (card.question && card.question.trim()) { + const questionText = createFlashcardText( + card.question, + maxTextWidth, + fontSize, + cardWidth / 2, + cardHeight / 4 + ); + if (questionText) { + cardGroup.children.append(questionText); + } + } + + // Create answer text with wrapping + const answerContent = card.answer && card.answer.trim() ? card.answer : "【Answer to be added】"; + if (answerContent) { + const answerText = createFlashcardText( + answerContent, + maxTextWidth, + fontSize, + cardWidth / 2, + (cardHeight * 3) / 4 + ); + if (answerText) { + cardGroup.children.append(answerText); + } + } + + // 在答案下方加一条分割线 + const line = editor.createRectangle(); + line.width = cardWidth * 0.8; + line.height = 2; + line.translation = { x: cardWidth * 0.1, y: cardHeight * 0.55 }; + line.fill = editor.makeColorFill(colorUtils.fromHex("#FFB6B6")); + cardGroup.children.append(line); + } +} + +function applyTemplate2(cardGroup, { cardWidth, cardHeight }, card, position = { x: 0, y: 0 }, index = 0) { + // 1. 背景矩形,使用模板色 + const rect = editor.createRectangle(); + rect.width = cardWidth; + rect.height = cardHeight; + applyCardCornerRadius(rect); + // 在 qa_two_side 模式下,使用 card.index 来选择颜色,确保问答对使用相同颜色 + const colorIndex = card.type === "question" || card.type === "answer" ? card.index : index; + const colorHex = TEMPLATE2_COLORS[colorIndex % TEMPLATE2_COLORS.length]; + rect.fill = editor.makeColorFill(colorUtils.fromHex(colorHex)); + // 边框 + rect.stroke = editor.makeStroke({ color: colorUtils.fromHex("#819A91"), width: 2 }); + cardGroup.children.append(rect); + + // 处理两种模式:qa_two_side 和 qa_same_side + if (card.type === "question" || card.type === "answer") { + // qa_two_side 模式 - 使用默认的处理函数 + if (card.type === "question") { + addQuestionOnlyContent(cardGroup, card, cardWidth, cardHeight); + } else { + addAnswerOnlyContent(cardGroup, card, cardWidth, cardHeight); + } + } else { + // qa_same_side 模式 + // Calculate text area parameters + const textPadding = Math.max(10, cardWidth * 0.05); + let maxTextWidth = cardWidth - textPadding * 2; + + // Dynamic font size calculation + const minCardDimension = Math.min(cardWidth, cardHeight); + let fontSize = Math.max(10, Math.min(minCardDimension / 60, 18)); + + // Smart text wrapping + const originalMaxTextWidth = maxTextWidth; + maxTextWidth = Math.min(maxTextWidth, fontSize * 30); + + if (maxTextWidth < originalMaxTextWidth * 0.7) { + const widthReductionFactor = maxTextWidth / originalMaxTextWidth; + fontSize = fontSize * Math.max(0.8, widthReductionFactor); + } + + // Create question text with wrapping + if (card.question && card.question.trim()) { + const questionText = createFlashcardText( + card.question, + maxTextWidth, + fontSize, + cardWidth / 2, + cardHeight / 4 + ); + if (questionText) { + cardGroup.children.append(questionText); + } + } + + // Create answer text with wrapping + const answerContent = card.answer && card.answer.trim() ? card.answer : "【Answer to be added】"; + if (answerContent) { + const answerText = createFlashcardText( + answerContent, + maxTextWidth, + fontSize, + cardWidth / 2, + (cardHeight * 3) / 4 + ); + if (answerText) { + cardGroup.children.append(answerText); + } + } + + // 在答案下方加一条分割线 + const line = editor.createRectangle(); + line.width = cardWidth * 0.8; + line.height = 2; + line.translation = { x: cardWidth * 0.1, y: cardHeight * 0.55 }; + line.fill = editor.makeColorFill(colorUtils.fromHex("#819A91")); + cardGroup.children.append(line); + } +} + +function applyTemplate4(cardGroup, { cardWidth, cardHeight }, card, position = { x: 0, y: 0 }, index = 0) { + // 1. 背景矩形,使用模板色 + const rect = editor.createRectangle(); + rect.width = cardWidth; + rect.height = cardHeight; + applyCardCornerRadius(rect); + // 在 qa_two_side 模式下,使用 card.index 来选择颜色,确保问答对使用相同颜色 + const colorIndex = card.type === "question" || card.type === "answer" ? card.index : index; + const colorHex = TEMPLATE4_COLORS[colorIndex % TEMPLATE4_COLORS.length]; + rect.fill = editor.makeColorFill(colorUtils.fromHex(colorHex)); + // 边框 + rect.stroke = editor.makeStroke({ color: colorUtils.fromHex("#A594F9"), width: 2 }); + cardGroup.children.append(rect); + + // 处理两种模式:qa_two_side 和 qa_same_side + if (card.type === "question" || card.type === "answer") { + // qa_two_side 模式 - 使用默认的处理函数 + if (card.type === "question") { + addQuestionOnlyContent(cardGroup, card, cardWidth, cardHeight); + } else { + addAnswerOnlyContent(cardGroup, card, cardWidth, cardHeight); + } + } else { + // qa_same_side 模式 + // Calculate text area parameters + const textPadding = Math.max(10, cardWidth * 0.05); + let maxTextWidth = cardWidth - textPadding * 2; + + // Dynamic font size calculation + const minCardDimension = Math.min(cardWidth, cardHeight); + let fontSize = Math.max(10, Math.min(minCardDimension / 60, 18)); + + // Smart text wrapping + const originalMaxTextWidth = maxTextWidth; + maxTextWidth = Math.min(maxTextWidth, fontSize * 30); + + if (maxTextWidth < originalMaxTextWidth * 0.7) { + const widthReductionFactor = maxTextWidth / originalMaxTextWidth; + fontSize = fontSize * Math.max(0.8, widthReductionFactor); + } + + // Create question text with wrapping + if (card.question && card.question.trim()) { + const questionText = createFlashcardText( + card.question, + maxTextWidth, + fontSize, + cardWidth / 2, + cardHeight / 4 + ); + if (questionText) { + cardGroup.children.append(questionText); + } + } + + // Create answer text with wrapping + const answerContent = card.answer && card.answer.trim() ? card.answer : "【Answer to be added】"; + if (answerContent) { + const answerText = createFlashcardText( + answerContent, + maxTextWidth, + fontSize, + cardWidth / 2, + (cardHeight * 3) / 4 + ); + if (answerText) { + cardGroup.children.append(answerText); + } + } + + // 在答案下方加一条分割线 + const line = editor.createRectangle(); + line.width = cardWidth * 0.8; + line.height = 2; + line.translation = { x: cardWidth * 0.1, y: cardHeight * 0.55 }; + line.fill = editor.makeColorFill(colorUtils.fromHex("#A594F9")); + cardGroup.children.append(line); + } +} + +start(); diff --git a/src/components/App.jsx b/src/components/App.jsx index a64630f..134361b 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -1,19 +1,23 @@ import React, { useState } from "react"; -import "./App.css"; -import Settings from "./settings"; -import Template from "./template"; -import Text from "./text"; +import Settings from "./settings.jsx"; +import TextTemplate from "./textTemplate.jsx"; +import Upload from "./upload.jsx"; const styles = { - tabBar: { + nav: { display: "flex", - paddingBottom: "15px", - position: "sticky", - top: 0, - zIndex: 2, - gap: "8px", - padding: "0 18px 18px" + flexDirection: "row", + // alignContent: 'center', + justifyContent: "space-between", + gap: "12px", + paddingBottom: "10px", + color: "#06001A", + fontFamily: "Avenir Next", + fontSize: "14px", + fontStyle: "normal", + fontWeight: 600, + lineHeight: "normal" }, tabButton: { flex: 1, @@ -25,40 +29,137 @@ const styles = { fontSize: 14, transition: "all 0.2s ease", backgroundColor: "#EBF3FE", - color: "#06001A", + color: "#06001A" }, tabButtonActive: { backgroundColor: "#1178FF", - color: "white", - }, + color: "white" + } }; -const App = ({ addOnSdk }) => { - const [activeTab, setActiveTab] = useState("Settings"); +const App = ({ sandboxProxy }) => { + const [extractedText, setExtractedText] = useState(""); + + // Settings state + const [flashcardSettings, setFlashcardSettings] = useState({ + totalCardNumber: 6 + }); + + // Template state + const [templateSetting, setTemplateSetting] = useState({ + template: "none", + cardSize: "102x152", + sided: "qa_same_side", + orientation: "horizontal" + }); + + // Text component state - lifted from Text component + const [entryTab, setEntryTab] = useState("Settings"); + const [cards, setCards] = useState([ + { question: "Metaphor", answer: "A comparison between two things or ideas where one becomes the other" }, + { question: "Membrane", answer: "" }, + { question: "Myth", answer: "" }, + { question: "Myth", answer: "" }, + { question: "Myth", answer: "" }, + { question: "Myth", answer: "" }, + { question: "Myth", answer: "" }, + { question: "Myth", answer: "" }, + + ]); + + const tabs = ["Upload", "Settings", "Flashcard"]; + + const handleSettingsChange = newSettings => { + setFlashcardSettings(newSettings); + }; + + const insertCards = async () => { + if (!sandboxProxy) { + console.log("sandboxProxy not available"); + return; + } + + try { + // Filter out empty cards + const validCards = cards.filter(card => card.question.trim() !== "" || card.answer.trim() !== ""); + + if (validCards.length === 0) { + console.log("Please fill in at least one card with question or answer"); + return; + } + + console.log(`Preparing to insert ${validCards.length} flashcards`); + + // pass layout settings + await sandboxProxy.createFlashcards(validCards, { + template: templateSetting?.template || "none", + cardSize: templateSetting?.cardSize || "102x152", + orientation: templateSetting?.orientation || "horizontal", + typeKey: templateSetting?.sided || "qa_same_side" + }); + + console.log(`Successfully inserted ${validCards.length} flashcards`); + } catch (error) { + console.error("Failed to insert flashcards:", error); + console.log("Error occurred while inserting flashcards, please check console"); + } + }; return ( -
-
- {["Settings", "Template", "Text"].map((t) => ( +
+ {tabs.indexOf(entryTab) != 0 && ( +
- ))} -
- -
- {activeTab == 'Settings' && } - {activeTab == 'Template' &&