From 45604d1a14c7174a8fc80d4136daa4a6f18cefd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 04:20:36 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix(notes):=20=D7=A9=D7=9E=D7=95=D7=A0?= =?UTF-8?q?=D7=94=20=D7=91=D7=90=D7=92=D7=99=D7=9D=20=D7=91=D7=9C=D7=95?= =?UTF-8?q?=D7=97=20=D7=94=D7=A4=D7=AA=D7=A7=D7=99=D7=9D,=20=D7=9B=D7=95?= =?UTF-8?q?=D7=9C=D7=9C=20=D7=A9=D7=A0=D7=99=20=D7=A9=D7=95=D7=A8=D7=A9?= =?UTF-8?q?=D7=99=D7=9D=20=D7=9C=D7=90=D7=95=D7=91=D7=93=D7=9F=20=D7=94?= =?UTF-8?q?=D7=AA=D7=95=D7=9B=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מדווח מהשימוש בפרודקשן. כל תיקון אומת בכרומיום אמיתי מול הקוד שלפניו — 13 מתוך 15 הבדיקות נופלות על הקוד הקודם, ומסלול פתקי הקבצים יוצא זהה לחלוטין (מיקום, מחלקות, position מחושב, מצב הכפתור, מפתח הקאש, בקשות). **התוכן ש"נמחק" — שני שורשים נפרדים, לא אחד** הרינדור *אינו* מסלול כתיבה, וזה נמדד ולא הונח: אחרי blur התוכן המלא (5,509 תווים) עדיין בטקסטריה, ואפס בקשות כתיבה יצאו. מה שקרה: 1. התצוגה רינדרה **רק** שורות צ'קבוקס והסתירה את הטקסטריה, בלי שום דרך חזרה. פתק עם 400 שורות ושורת משימה אחת נראה כאילו נמחק, והפך לקריאה-בלבד לתמיד. עכשיו מרונדרות כל השורות, ולחיצה על טקסט מחזירה לעריכה עם הסמן בשורה שנלחצה. 2. השרת חותך ל-5,000 תווים **בשקט**: הדבקה של 16,291 תווים נשמרת כ-5,000 בלי שגיאה (נמדד מול ``_decode_content_b64`` האמיתי). עכשיו הממשק מציג חיווי אדום עם מספר התווים בפועל, וכשל שמירה מסמן את הפתק במקום להיכתב ל-console בלבד. **מרחב הקואורדינטות — הפתק שזז בכל רענון** המיקום נשמר תמיד בקואורדינטות של המסמך, אבל פתק ``surface`` ממוקם ``absolute`` בתוך המשטח. בקובץ שני המרחבים חופפים; בלוח ההפרש הוא גובה כל מה שמעל המשטח, והפתק ירד בדיוק בו בכל שמירה (נמדד: style=(20,30), נשמר=(71,335)). ``_positionOrigin`` מחזיר את הראשית של מרחב היעד, ושתי הפעולות משתמשות בה. **כפתור הנעיצה בלוח** ``_pinNote`` כותב סנטינל ל-``anchor_id``, אבל ``_resolveMode`` קורא את ``mode`` ראשון — ולכן בלוח הלחיצה לא שינתה כלום חוץ מלכתוב מיקום מחדש, כלומר להזיז את הפתק. עכשיו הכפתור מחליף בין ``surface`` ל-``screen``, ו-``mode`` מתקבל בעדכון (יחיד ובאטצ') — לפתקי לוח בלבד, כי על פתק קובץ הוא היה משתלט על העיגון לשורות המקור. **והשאר** - "הלוח ריק" הוצג תמיד: ``display:flex`` של המחבר מנצח את ``[hidden]`` של הדפדפן. הכלל מוחזר לכל הקובץ, לא רק למקום שנתפס. - פתק שנגרר מחוץ למשטח נשאר תקוע — הכותרת היא ידית הגרירה. נוספה חסימה למעלה ולצדדים, בזמן הגרירה ובזמן הרינדור. - משטח הלוח גדל (‎+20% שטח ב-1280 רוחב, יותר במסכים רחבים). - מרווח בין כפתורי הפתק, בלי לשנות את גובה הכותרת. הבדיקות ל-``_parseTasks`` הוחלפו בבדיקות של מסלול הרינדור עצמו: הן עברו בהצלחה בזמן שהתצוגה הסתירה 400 שורות, כי בדקו את המסננת ולא את מה שהמשתמש רואה. נוספה בדיקה שנועלת את הכלל "רינדור הוא חד-כיווני". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019YULCppaQRPYN1RgeY6NBu --- docs/user/sticky_notes.rst | 11 +- tests/sticky-notes-target.test.js | 155 +++++++++++++--- tests/test_note_boards_api.py | 91 ++++++++++ webapp/static/css/note-boards.css | 20 +- webapp/static/css/sticky-notes.css | 49 ++++- webapp/static/js/sticky-notes.js | 281 +++++++++++++++++++++++++---- webapp/sticky_notes_api.py | 36 +++- webapp/templates/note_board.html | 2 +- 8 files changed, 571 insertions(+), 74 deletions(-) diff --git a/docs/user/sticky_notes.rst b/docs/user/sticky_notes.rst index e37204a8e..24be9ea08 100644 --- a/docs/user/sticky_notes.rst +++ b/docs/user/sticky_notes.rst @@ -44,7 +44,7 @@ מגבלות, פרטיות ואבטחה ----------------------- - מגבלות שימוש: עד 200 פתקים ללוח, ועד 1000 פתקים לכל משתמש — התקרה השנייה חלה על כל הפתקים, גם אלה שעל קבצים. עד לגרסה זו שתיהן היו מתועדות אך נאכפו רק במסלול ה-MCP, וגם שם באופן חלקי; כעת הן נאכפות בשכבת ה-API. משתמשי אדמין פטורים. -- אורך פתק: עד 5,000 תווים. +- אורך פתק: עד 5,000 תווים. מעבר לתקרה השרת שומר את 5,000 התווים הראשונים בלבד; הממשק מציג חיווי אדום עם מספר התווים בפועל ברגע שחורגים, כדי שהחיתוך לא יקרה בשקט. - פרטיות: פתקים נשמרים לחשבון המשתמש בלבד, ואינם מוצגים לאחרים כברירת מחדל. לוחות פתקים @@ -64,7 +64,9 @@ * - מחיקת לוח - הפתקים שעליו עוברים ללוח ברירת המחדל, והממשק מדווח כמה עברו. אם ההעברה לא הושלמה — הלוח **אינו** נמחק, כדי שאף פתק לא יתייתם. * - מצב הפתק - - "מוצמד ללוח" (ברירת המחדל) — הפתק חלק מהמשטח וזז איתו. "צמוד למסך" — נשאר מול העיניים גם כשגוללים בלוח. + - "מוצמד ללוח" (ברירת המחדל) — הפתק חלק מהמשטח וזז איתו. "צמוד למסך" — נשאר מול העיניים גם כשגוללים בלוח. הכפתור ``📌`` בראש הפתק מחליף ביניהם, והוא דלוק רק כשהפתק צמוד למסך. + * - גבולות המשטח + - פתק אינו יוצא מגבולות הלוח. גרירה כלפי מעלה נעצרת בקצה העליון, כי הכותרת של הפתק היא גם ידית הגרירה — פתק שהכותרת שלו מעל הקצה היה נשאר תקוע. בלוח אין עיגון לשורת מקור, כי אין טקסט להיצמד אליו — ולכן גם **בעיית הפתק המיותם אינה קיימת שם**. שום דבר לא זז מתחת לפתק על לוח. @@ -77,6 +79,11 @@ שתי התנהגויות שכדאי להכיר: שורות ``- [ ]`` זהות מזוהות לפי **סדר ההופעה** שלהן, כך שאפשר לסמן כל אחת בנפרד; ושורת ``- [ ]`` בתוך בלוק קוד נספרת גם היא, כי הפתק אינו עובר פרסור מארקדאון מלא. +כשיש בפתק צ'קבוקס אחד לפחות, הפתק עובר לתצוגה שבה התיבות ניתנות ללחיצה. **התצוגה מציגה את כל תוכן הפתק** — שורות שאינן משימה נשארות גלויות כטקסט. לחיצה על טקסט כלשהו בתצוגה מחזירה לעריכה, עם הסמן בתחילת השורה שנלחצה. + +.. note:: + התצוגה היא **חד-כיוונית**: ``content`` הופך ל-HTML, ולעולם לא להפך. הדברים היחידים שכותבים לתוכן הפתק הם תיבת העריכה עצמה וראוט הצ'קבוקס, שמקבל מספר סידורי ומצב רצוי — לא טקסט. לכן רינדור אינו יכול לאבד שורה. + תזכורות בפתקי לוח ~~~~~~~~~~~~~~~~~~ עובדות בדיוק כמו בפתקי קובץ. לחיצה על ההתראה או על הפעמון פותחת את הקישור הקבוע ``/note/``, שמפנה למקום שבו הפתק באמת יושב — קובץ או לוח. diff --git a/tests/sticky-notes-target.test.js b/tests/sticky-notes-target.test.js index 8424aab24..6d25dd41f 100644 --- a/tests/sticky-notes-target.test.js +++ b/tests/sticky-notes-target.test.js @@ -183,38 +183,151 @@ check('anchored בלי מקור שורות יורד ל-surface', () => { }); -// -- צ'קבוקסים: הפרסור בצד הלקוח -- +// -- צ'קבוקסים: תצוגת התוכן -- // // הזרימה המלאה (fetch, אימות, חזרה אחורה) נבדקת בפייתון ב- -// tests/test_sticky_notes_tasks.py, כי שם היא באמת נכתבת למסד. כאן -// נבדק רק החלק שחי בדפדפן: זיהוי השורות והמצב שלהן. +// tests/test_sticky_notes_tasks.py, כי שם היא באמת נכתבת למסד. כאן נבדק +// מה שחי בדפדפן: מה ``_syncTaskView`` מרנדר, ומה הוא **לא** נוגע בו. +// +// למה DOM מדומה ולא בדיקת פונקציית עזר טהורה: הגרסה הקודמת בדקה +// ``_parseTasks``, שהחזירה רק את שורות המשימה. היא עברה בהצלחה בזמן +// שהתצוגה הסתירה 400 שורות טקסט — כי היא בדקה את המסננת, לא את מה +// שהמשתמש רואה. הבדיקה חייבת לגעת במה שנשבר. + +/** DOM מינימלי — בדיוק מה ש-``_syncTaskView`` ו-``_enterEditAt`` נוגעים בו. */ +class FakeEl { + constructor(tag) { + this.tagName = tag; this.children = []; this.dataset = {}; this.style = {}; + this.hidden = false; this._text = ''; this._attrs = {}; this._classes = new Set(); + this.classList = { + add: (c) => this._classes.add(c), + remove: (c) => this._classes.delete(c), + contains: (c) => this._classes.has(c), + toggle: (c, on) => { if (on) this._classes.add(c); else this._classes.delete(c); }, + }; + } + set className(v) { this._classes = new Set(String(v || '').split(/\s+/).filter(Boolean)); } + get className() { return [...this._classes].join(' '); } + appendChild(c) { this.children.push(c); return c; } + set textContent(v) { this.children = []; this._text = String(v); } + get textContent() { return this.children.length ? this.children.map((c) => c.textContent).join('') : this._text; } + setAttribute(k, v) { this._attrs[k] = String(v); } + getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; } + addEventListener() {} + focus() { FakeEl.focused = this; } + _matches(sel) { return sel.startsWith('.') ? this._classes.has(sel.slice(1)) : this.tagName === sel; } + querySelector(sel) { + for (const c of this.children) { + if (c._matches(sel)) return c; + const deep = c.querySelector(sel); + if (deep) return deep; + } + return null; + } + querySelectorAll(sel) { + const out = []; + for (const c of this.children) { if (c._matches(sel)) out.push(c); out.push(...c.querySelectorAll(sel)); } + return out; + } + getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0 }; } +} + +sandbox.document.createElement = (tag) => new FakeEl(tag); + +/** פתק מדומה עם טקסטריה ותצוגה, כמו ש-``_renderNote`` בונה. */ +function makeNote(content) { + const el = new FakeEl('div'); + el.className = 'sticky-note'; + const ta = new FakeEl('textarea'); + ta.className = 'sticky-note-content'; + ta.value = content; + const view = new FakeEl('div'); + view.className = 'sticky-note-tasks'; + el.appendChild(ta); + el.appendChild(view); + return { el, ta, view }; +} + +const CONTENT = 'כותרת\n- [ ] אחת\nשורת טקסט\n- פריט רגיל\n- [x] שתיים'; -check('פרסור: רק שורות משימה נספרות', () => { - const tasks = fileMgr._parseTasks('כותרת\n- [ ] אחת\nשורה\n- פריט\n- [x] שתיים'); - eq(tasks.length, 2, 'כמות'); - eq(tasks[0].checked, false, 'ראשונה'); - eq(tasks[1].checked, true, 'שנייה'); +check('תצוגה: **כל** השורות מרונדרות, לא רק המשימות', () => { + // זה הבאג שמחק 400 שורות מהמסך. הגרסה הקודמת הייתה מרנדרת 2. + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + eq(view.children.length, 5, 'מספר השורות'); }); -check('פרסור: X גדולה נחשבת מסומנת', () => { - eq(fileMgr._parseTasks('- [X] בוצע')[0].checked, true); +check('תצוגה: טקסט שאינו משימה נשאר גלוי', () => { + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + eq(view.textContent.includes('שורת טקסט'), true, 'טקסט חופשי'); + eq(view.textContent.includes('- פריט רגיל'), true, 'פריט רשימה שאינו משימה'); }); -check('פרסור: הטקסט מנוקה מהסוגריים', () => { - eq(fileMgr._parseTasks(' * [ ] משימה מוזחת')[0].text, 'משימה מוזחת'); +check('תצוגה: הסידור נספר על שורות משימה בלבד', () => { + // הסידור הוא מה שנשלח לשרת. ספירה על כל השורות הייתה מסמנת שורה אחרת. + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + const boxes = view.querySelectorAll('.sticky-task-box'); + eq(boxes.length, 2, 'מספר תיבות'); + eq(boxes[0].dataset.taskIndex, '0'); + eq(boxes[1].dataset.taskIndex, '1'); + eq(boxes[1].checked, true, '[x] מסומנת'); +}); + +check('תצוגה: X גדולה נחשבת מסומנת', () => { + const { el, view } = makeNote('- [X] בוצע'); + fileMgr._syncTaskView(el); + eq(view.querySelectorAll('.sticky-task-box')[0].checked, true); +}); + +check('תצוגה: תוכן בלי משימות משאיר את הטקסטריה גלויה', () => { + // זה מה ששומר על פתק רגיל בדיוק כפי שהיה + const { el, ta, view } = makeNote('סתם טקסט'); + fileMgr._syncTaskView(el); + eq(view.hidden, true, 'התצוגה מוסתרת'); + eq(ta.hidden, false, 'הטקסטריה גלויה'); +}); + +check('רינדור הוא חד-כיווני: התוכן לא נכתב מהתצוגה', () => { + // **החוק שמעל כל השאר.** ``content → HTML``, לעולם לא חזרה. היחידים + // שרשאים לכתוב ל-content הם הטקסטריה וראוט ``/task``. + const { el, ta } = makeNote(CONTENT); + const before = ta.value; + const saves = []; + const realQueue = fileMgr._queueSave; + fileMgr._queueSave = (...a) => { saves.push(a); }; + try { + fileMgr._syncTaskView(el); + fileMgr._syncTaskView(el, { editing: true }); + fileMgr._syncTaskView(el); + } finally { + fileMgr._queueSave = realQueue; + } + eq(ta.value, before, 'התוכן לא השתנה'); + eq(saves.length, 0, 'לא נשלחה שום שמירה'); }); -check('פרסור: הסדר הוא סדר המופע, וזה מה שנשלח לשרת', () => { - // שלוש שורות זהות — האינדקס הוא הדבר היחיד שמבדיל ביניהן - const tasks = fileMgr._parseTasks('- [ ] לבדוק\n- [ ] לבדוק\n- [ ] לבדוק'); - eq(tasks.length, 3); +check('חזרה לעריכה: הסמן נוחת בתחילת השורה שנלחצה', () => { + // בלי זה התצוגה היא דלת חד-כיוונית — אי אפשר להוסיף או למחוק מלל + const { el, ta, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + const row = view.children[2]; // 'שורת טקסט' + fileMgr._enterEditAt(el, parseInt(row.dataset.charOffset, 10)); + eq(ta.hidden, false, 'הטקסטריה חזרה'); + eq(FakeEl.focused, ta, 'הפוקוס עבר לטקסטריה'); + eq(CONTENT.slice(ta.selectionStart, ta.selectionStart + 4), 'שורת', 'מיקום הסמן'); }); -check('פרסור: תוכן בלי משימות מחזיר רשימה ריקה', () => { - // זה מה שמשאיר פתק רגיל בדיוק כפי שהיה — התצוגה נשארת מוסתרת - eq(fileMgr._parseTasks('סתם טקסט').length, 0); - eq(fileMgr._parseTasks('').length, 0); - eq(fileMgr._parseTasks(null).length, 0); +check('אורך: מעל התקרה מוצג חיווי, מתחתיה לא', () => { + // השרת חותך ל-5,000 תווים בשקט. אם זה לא נראה כאן — זו מחיקת מידע. + const { el, ta } = makeNote('קצר'); + eq(fileMgr._checkContentLength(el, ta.value), false, 'תוכן קצר'); + eq(el.querySelector('.sticky-note-warn'), null, 'אין חיווי'); + eq(fileMgr._checkContentLength(el, 'א'.repeat(5001)), true, 'תוכן ארוך'); + const warn = el.querySelector('.sticky-note-warn'); + eq(warn !== null, true, 'יש חיווי'); + eq(warn.textContent.includes('5001'), true, 'החיווי מציין את האורך בפועל'); }); console.log(`\n${passed} עברו, ${failed} נכשלו`); diff --git a/tests/test_note_boards_api.py b/tests/test_note_boards_api.py index b8535441c..381f6f584 100644 --- a/tests/test_note_boards_api.py +++ b/tests/test_note_boards_api.py @@ -451,3 +451,94 @@ def test_count_failure_is_distinguishable_from_empty(client): board = client.get("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/api/note-boards").get_json()["boards"][0] assert "note_count" not in board + + +# -- ``mode``: הכפתור בלוח נשמר -- +# +# ``mode`` נכתב עד היום רק ביצירה, ולכן כפתור המצב לא יכול היה לשנות כלום +# שנשמר. מה שכן קרה בלחיצה עליו זה שהמיקום נכתב מחדש — כלומר הפתק זז ולא +# שינה מצב. + + +def _board_note(client, **extra): + doc = {"_id": 1, "user_id": 7, "board_id": "b1", "content": "", "mode": "surface"} + doc.update(extra) + client.db.sticky_notes.docs.append(doc) + return doc + + +def test_mode_update_is_persisted_for_board_note(client): + doc = _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "screen"}) + + assert res.status_code == 200, res.get_json() + assert doc["mode"] == "screen" + + +def test_mode_alone_is_enough_to_update(client): + """``mode`` בלבד אינו "אין שדות לעדכון".""" + _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "screen"}) + + assert res.status_code == 200 + assert res.get_json()["ok"] is True + + +def test_invalid_mode_is_rejected(client): + doc = _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "anchored"}) + + assert res.status_code == 400 + assert res.get_json()["error"] == "invalid_mode" + assert doc["mode"] == "surface", "הפתק לא השתנה" + + +def test_mode_is_refused_on_a_file_note(client): + """פתק קובץ אינו מקבל ``mode``. + + ``_resolveMode`` בלקוח קורא את ``mode`` **לפני** הסנטינלים, ולכן שדה + כזה על פתק קובץ היה משתלט על מסלול העיגון לשורות המקור. + """ + doc = {"_id": 2, "user_id": 7, "file_id": "f1", "content": "", "scope_id": "s1"} + client.db.sticky_notes.docs.append(doc) + + res = client.put("/api/sticky-notes/note/2", json={"mode": "screen"}) + + assert res.status_code == 400 + assert res.get_json()["error"] == "mode_not_supported" + assert "mode" not in doc + + +def test_mode_update_works_through_the_batch_route(client): + """זה המסלול שהלקוח באמת משתמש בו — ``_performSaveBatch``.""" + doc = _board_note(client) + + res = client.post( + "/api/sticky-notes/batch", + json={"updates": [{"id": "1", "mode": "screen", "position": {"x": 5, "y": 6}}]}, + ) + + assert res.status_code == 200 + assert res.get_json()["results"][0]["ok"] is True + assert doc["mode"] == "screen" + assert (doc["position_x"], doc["position_y"]) == (5, 6) + + +def test_batch_rejects_invalid_mode_without_touching_the_note(client): + doc = _board_note(client) + + res = client.post( + "/api/sticky-notes/batch", + json={"updates": [{"id": "1", "mode": "לא-קיים", "position": {"x": 5, "y": 6}}]}, + ) + + result = res.get_json()["results"][0] + assert result["ok"] is False + assert result["status"] == 400 + assert result["error"] == "invalid_mode" + # הפריט כולו נדחה, ולא "חצי נשמר" + assert doc["mode"] == "surface" + assert "position_x" not in doc diff --git a/webapp/static/css/note-boards.css b/webapp/static/css/note-boards.css index a83235177..8b66f8571 100644 --- a/webapp/static/css/note-boards.css +++ b/webapp/static/css/note-boards.css @@ -1,10 +1,18 @@ /* לוחות פתקים — רשימה ומשטח. + * + * ``hidden`` מול ``display`` של המחבר: הדפדפן מגדיר ``[hidden]{display:none}`` + * במקור ה-user-agent, וכל הכרזת ``display`` של המחבר מנצחת אותו — גם בלי + * ספציפיות גבוהה. ``.board-empty`` הגדיר ``display:flex``, ולכן הכיתוב + * "הלוח ריק" הוצג גם כשהיו פתקים על הלוח. הכלל הבא מחזיר ל-``hidden`` את + * המשמעות שלו בכל הקובץ, ולא רק במקום היחיד שנתפס. * * רשת הנקודות היא CSS טהור בכוונה: אפס נכסים, אפס ווטרמארק, ואפס בייטים * להוריד. היא גם מקבלת את צבע ערכת הנושא הפעילה אוטומטית, מה שאיור סטטי * לא היה עושה. */ +[hidden] { display: none !important; } + /* ---------- רשימת הלוחות ---------- */ .boards-page { @@ -97,14 +105,21 @@ /* ---------- משטח הלוח ---------- */ +/* עמוד המשטח מנצל את כל הרוחב ורוב הגובה. ברשימת הלוחות עמודה צרה + היא הנכונה, אבל משטח עבודה שמוגבל ל-1100px משאיר שוליים מתים משני + הצדדים ועוד 4rem ריקים מתחתיו. */ +.boards-page.is-board-surface { + max-width: none; + padding: .75rem .75rem 1rem; +} + .board-surface-wrap { position: relative; - min-height: calc(100vh - 140px); } .board-surface { position: relative; /* פתק במצב surface הוא absolute יחסית לזה */ - min-height: calc(100vh - 160px); + min-height: calc(100vh - 130px); border-radius: 16px; overflow: auto; border: 1px solid var(--glass-border, rgba(0, 0, 0, .08)); @@ -168,7 +183,6 @@ .board-msg.is-error { background: rgba(220, 53, 69, .12); color: #b02a37; } .board-msg.is-info { background: rgba(103, 126, 234, .12); } -.board-msg[hidden] { display: none; } @media (prefers-color-scheme: dark) { .board-surface { diff --git a/webapp/static/css/sticky-notes.css b/webapp/static/css/sticky-notes.css index 7c44516ad..c47620bca 100644 --- a/webapp/static/css/sticky-notes.css +++ b/webapp/static/css/sticky-notes.css @@ -24,8 +24,11 @@ background: rgba(0,0,0,.06); } .sticky-note-drag{ width: 100%; height: 16px; cursor: move; touch-action: none; } -.sticky-note-actions{ display:flex; gap:6px; } -.sticky-note-btn{ border:none; background: transparent; cursor: pointer; font-size: 16px; transition: background-color .18s ease, color .18s ease; } +/* מרווח בין הכפתורים: הם קטנים, וצמודים זה לזה קשה לפגוע בהם באצבע. + ההגדלה היא במרווח ובריפוד אופקי בלבד — גובה הכותרת לא משתנה, ולכן + ``calc(100% - 42px)`` של גוף הפתק נשאר נכון. */ +.sticky-note-actions{ display:flex; gap:10px; } +.sticky-note-btn{ border:none; background: transparent; cursor: pointer; font-size: 16px; padding: 0 3px; min-width: 22px; transition: background-color .18s ease, color .18s ease; } .sticky-note-pin{ opacity:.8; } .sticky-note-pin:hover{ opacity:1; } .sticky-note-pin.is-active{ @@ -75,26 +78,37 @@ /* ---------- צ'קבוקסים בתוך פתק ---------- */ +/* ``flex: 1`` לא עשה כלום — ``.sticky-note`` אינו קונטיינר flex — ולכן + לתצוגה לא היה גובה משלה כלל. עכשיו היא בדיוק אותו ארגז כמו ה-textarea + שהיא מחליפה, כולל גלילה פנימית: פתק שגדל לבד מזיז את מה שסביבו. */ .sticky-note-tasks { - flex: 1; - overflow-y: auto; /* גלילה בתוך הפתק, לא גדילה אוטומטית: פתק - שגדל לבד מזיז את מה שסביבו ושובר סידור ידני */ - padding: .35rem .5rem; - font-size: .9rem; - line-height: 1.5; + width: 100%; + height: calc(100% - 42px); + overflow-y: auto; + padding: 8px; + font-size: 14px; + line-height: 1.35; + color: #222; + cursor: text; /* הרמז שאפשר ללחוץ ולחזור לעריכה */ + white-space: pre-wrap; + word-break: break-word; } .sticky-note-tasks[hidden] { display: none; } +.sticky-note.is-minimized .sticky-note-tasks{ display:none; } + +/* שורה שאינה משימה — הטקסט הרגיל של הפתק. היא נשארת גלויה, וזה כל + ההבדל בין "יש כאן צ'קבוקס" לבין "כל שאר הפתק נעלם". */ +.sticky-task-line { min-height: 1.35em; } .sticky-task { display: flex; align-items: flex-start; gap: .4rem; - cursor: pointer; padding: .1rem 0; } -.sticky-task-box { margin-top: .25rem; flex: 0 0 auto; } +.sticky-task-box { margin-top: .25rem; flex: 0 0 auto; cursor: pointer; } .sticky-task-box:checked + .sticky-task-text { text-decoration: line-through; @@ -110,3 +124,18 @@ outline: 2px solid #dc3545; outline-offset: 1px; } + +/* חיווי אורך — התקרה נאכפת בשרת בחיתוך שקט, ולכן היא חייבת להיראות כאן */ +.sticky-note-warn { + position: absolute; left: 0; right: 0; bottom: 0; + padding: 4px 8px; + font-size: 12px; line-height: 1.3; + background: #dc3545; color: #fff; +} + +/* כשל שמירה — עד היום הוא היה ב-console בלבד */ +.sticky-note.has-save-error, +.sticky-note.has-length-error { + outline: 2px solid #dc3545; + outline-offset: 1px; +} diff --git a/webapp/static/js/sticky-notes.js b/webapp/static/js/sticky-notes.js index 8e5a9a3bc..87e207d6e 100644 --- a/webapp/static/js/sticky-notes.js +++ b/webapp/static/js/sticky-notes.js @@ -135,6 +135,15 @@ // לבין פתק ישן שעדיין לא עבר מיגרציה ל־[data-source-line]. const FLOATING_SENTINEL = '__floating__'; const AUTO_SAVE_DEBOUNCE_MS = 500; + //: תקרת התוכן שהשרת מקבל (``_sanitize_text``/``_decode_content_b64`` + //: ב-``webapp/sticky_notes_api.py``, ומתועדת ב-``docs/user/sticky_notes.rst``). + //: מעבר לזה ה-PUT נדחה ב-400, והלקוח רק החזיר את השינוי ל-pending — כלומר + //: הדבקה של מסמך ארוך פשוט לא נשמרה, בלי שום סימן למשתמש. + const MAX_NOTE_CHARS = 5000; + //: שורת משימה: ``- [ ] טקסט`` או ``* [x] טקסט``, עם הזחה כלשהי. + //: אותה תבנית בדיוק כמו ``_TASK_RE`` ב-``sticky_notes_tasks.py`` — שם + //: מתבצעת הכתיבה, וכאן רק התצוגה. סטייה ביניהן הייתה מזיזה סידורים. + const TASK_LINE_RE = /^([ \t]*[-*][ \t]\[)([ xX])(\].*)$/; const AUTO_SAVE_FORCE_INTERVAL_MS = 3500; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h @@ -409,6 +418,16 @@ const box = ev.target; if (box && box.type === 'checkbox') this._onTaskToggle(el, box); }); + // לחיצה בכל מקום בתצוגה שאינו התיבה עצמה מחזירה לעריכה, בשורה + // שנלחצה. זו הדרך היחידה החוצה מהתצוגה — בלעדיה פתק עם צ'קבוקס + // אחד הופך לקריאה-בלבד לתמיד. + taskView.addEventListener('click', (ev) => { + const t = ev.target; + if (t && t.classList && t.classList.contains('sticky-task-box')) return; + const row = (t && t.closest) ? t.closest('.sticky-task-line') : null; + const offset = row ? parseInt(row.dataset.charOffset, 10) : NaN; + this._enterEditAt(el, Number.isFinite(offset) ? offset : null); + }); el.appendChild(header); el.appendChild(textarea); @@ -431,6 +450,7 @@ }); textarea.addEventListener('input', () => { + this._checkContentLength(el, textarea.value); this._queueSave(el, { content: textarea.value }); }); textarea.addEventListener('keydown', (ev) => { @@ -608,11 +628,67 @@ return false; } - _notePayloadFromEl(el){ - const rect = el.getBoundingClientRect(); + // הנקודה שממנה נמדדים ``position.x/y`` של פתק, בקואורדינטות של אזור + // התצוגה. + // + // **זה שורש הבאג "הפתק זז קצת למטה בכל רענון".** השמירה חישבה תמיד + // קואורדינטות של המסמך (``rect + scroll``), אבל פתק במצב ``surface`` + // ממוקם ``absolute`` בתוך הקונטיינר — כלומר הדפדפן קורא בדיוק את + // אותם מספרים ביחס למשטח הלוח. בקובץ שני המרחבים חופפים, כי + // הקונטיינר הוא ה-body והוא לא ממוקם; בלוח המשטח מתחיל כמה מאות + // פיקסלים מתחת לראש המסמך, והפתק ירד בדיוק בהפרש הזה בכל שמירה. + // + // מסלול הקובץ יוצא ביט-זהה: כל ענף שאינו קונטיינר ממוקם מחזיר + // ``-scroll``, ואז ``rect.left - (-scroll.x)`` הוא בדיוק החישוב הישן. + _positionOrigin(el, mode){ const scroll = getScrollOffsets(); - const x = Math.round(rect.left + scroll.x); - const y = Math.round(rect.top + scroll.y); + const docOrigin = { x: -scroll.x, y: -scroll.y }; + let resolved = mode; + if (!resolved) { + const entry = this._getEntry(el); + resolved = this._resolveMode(entry ? entry.data : null); + } + // ``screen`` הוא ``position: fixed`` ונשמר במרחב המסמך, כפי שהיה. + if (resolved === 'screen') return docOrigin; + const parent = this.container; + if (!parent || parent === document.body || parent === document.documentElement) return docOrigin; + try { + const r = parent.getBoundingClientRect(); + // קופסת ה-padding של האב הממוקם, לפני הגלילה הפנימית שלו — + // בדיוק המרחב ש-``left``/``top`` של ילד ``absolute`` נמדדים בו. + return { + x: r.left + (parent.clientLeft || 0) - (parent.scrollLeft || 0), + y: r.top + (parent.clientTop || 0) - (parent.scrollTop || 0) + }; + } catch(_) { return docOrigin; } + } + + // פתק לא יוצא מגבולות המשטח. + // + // בלי זה החלק העליון של הפתק — שהוא גם ידית הגרירה — יכול לעלות מעל + // הקצה, ואז אין שום דרך להחזיר אותו: הגרירה היא לחיצה על הכותרת, + // והכותרת מחוץ למסך. + _clampToSurface(el, x, y, note){ + try { + const parent = this.container; + if (!parent || typeof parent.clientWidth !== 'number' || parent.clientWidth <= 0) { + return { x, y }; + } + let w = (note && note.size && typeof note.size.width === 'number') ? note.size.width : 0; + if (!w) { try { w = Math.round(el.getBoundingClientRect().width) || 0; } catch(_) { w = 0; } } + if (!w) w = 260; + const maxX = Math.max(0, parent.clientWidth - w); + // ציר ה-Y נחסם מלמטה בלבד: המשטח גליל, וגרירה כלפי מטה מגדילה + // אותו — כלומר פתק "רחוק" עדיין נגיש, בניגוד לפתק מעל הקצה. + return { x: clamp(Math.round(x), 0, maxX), y: Math.max(0, Math.round(y)) }; + } catch(_) { return { x, y }; } + } + + _notePayloadFromEl(el, mode){ + const rect = el.getBoundingClientRect(); + const origin = this._positionOrigin(el, mode); + const x = Math.round(rect.left - origin.x); + const y = Math.round(rect.top - origin.y); const w = Math.round(rect.width); const h = Math.round(rect.height); const payload = { position: { x, y }, size: { width: w, height: h } }; @@ -626,13 +702,20 @@ return this.notes.get(id) || null; } - _updatePinButtonState(el, isPinned){ + _updatePinButtonState(el, isActive){ const pinBtn = el ? el.querySelector('.sticky-note-pin') : null; if (!pinBtn) return; - const active = !!isPinned; + const active = !!isActive; pinBtn.classList.toggle('is-active', active); pinBtn.setAttribute('aria-pressed', active ? 'true' : 'false'); - pinBtn.title = active ? 'בטל נעיצה' : 'נעץ פתק למסמך'; + if (this.boardId) { + // בלוח הכפתור מחליף בין "יושב על הלוח" (ברירת המחדל, כבוי) לבין + // "צף מול המסך" (דלוק). "נעיצה למסמך" היא מושג של קובץ ואין לה + // מובן כאן — ולכן גם אין סיבה שהכפתור יידלק כבר בפתיחה. + pinBtn.title = active ? 'החזר את הפתק ללוח' : 'הצמד את הפתק למסך'; + } else { + pinBtn.title = active ? 'בטל נעיצה' : 'נעץ פתק למסמך'; + } } // ----- צ'קבוקסים ----- @@ -642,47 +725,109 @@ // שהתצוגה לא תשקר: אם הכתיבה לא נקלטה, הסימון חוזר אחורה ומוצגת // שגיאה. כשל שקט הוא בדיוק מה שהאפיון אוסר. - _parseTasks(text){ - const lines = String(text == null ? '' : text).split('\n'); - const re = /^([ \t]*[-*][ \t]\[)([ xX])(\].*)$/; - const out = []; - lines.forEach((line) => { - const m = re.exec(line); - if (m) out.push({ checked: m[2].toLowerCase() === 'x', text: m[3].slice(1).trim() }); - }); - return out; - } - + // בונה את התצוגה מ**כל** השורות, לא רק משורות המשימה. + // + // הגרסה הראשונה רינדרה רק את שורות הצ'קבוקס והסתירה את ה-textarea. + // התוצאה: פתק עם 400 שורות ושורת משימה אחת נראה כאילו נמחק — כל השאר + // פשוט לא הוצג, ולא הייתה שום דרך לחזור לעריכה. התוכן היה בטקסטאריה + // כל הזמן (נמדד בדפדפן: 5,509 תווים לפני ואחרי, אפס בקשות כתיבה), + // אבל מבחינת המשתמש זה אובדן מידע — ומבחינת התוצאה זה נכון. + // + // **החוק שמעל הפונקציה הזו: רינדור הוא חד-כיווני.** ``content → + // HTML``, לעולם לא חזרה. היחידים שרשאים לכתוב ל-``content`` הם + // ה-textarea עצמו וראוט ``/task`` (סידורי + מצב). על הכלל הזה יש + // בדיקה — ראו ``tests/sticky-notes-target.test.js``. _syncTaskView(el, opts){ try { const view = el.querySelector('.sticky-note-tasks'); const textarea = el.querySelector('.sticky-note-content'); if (!view || !textarea) return; const editing = !!(opts && opts.editing); - const tasks = this._parseTasks(textarea.value); - if (editing || !tasks.length) { + const lines = String(textarea.value == null ? '' : textarea.value).split('\n'); + const hasTask = lines.some((line) => TASK_LINE_RE.test(line)); + if (editing || !hasTask) { view.hidden = true; textarea.hidden = false; return; } view.textContent = ''; - tasks.forEach((task, idx) => { - const label = createEl('label', 'sticky-task'); - const box = createEl('input', 'sticky-task-box'); - box.type = 'checkbox'; - box.checked = task.checked; - box.dataset.taskIndex = String(idx); - const span = createEl('span', 'sticky-task-text'); - span.textContent = task.text; - label.appendChild(box); - label.appendChild(span); - view.appendChild(label); + let taskIndex = 0; + let charOffset = 0; + lines.forEach((line) => { + const m = TASK_LINE_RE.exec(line); + const row = createEl('div', 'sticky-task-line'); + // מיקום התו הראשון של השורה בתוך התוכן — כדי שלחיצה תחזיר + // לעריכה בדיוק בשורה שנלחצה, ולא בתחילת הפתק. + row.dataset.charOffset = String(charOffset); + if (m) { + row.classList.add('sticky-task'); + const box = createEl('input', 'sticky-task-box'); + box.type = 'checkbox'; + box.checked = m[2].toLowerCase() === 'x'; + box.dataset.taskIndex = String(taskIndex); + taskIndex += 1; + const span = createEl('span', 'sticky-task-text'); + span.textContent = m[3].slice(1).trim(); + // התיבה והטקסט הם אחים ולא ``