diff --git a/README.md b/README.md index d9d09220..4cb757f5 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,74 @@ const resolved = resolution.status === "uniform" ? resolution.value : void 0; התיקון החיווי נשאר יציב לאורך כל התרחיש — הקלדה, הזזת סמן, סימון טווח ולחיצה במקום אחר — ב‑0 הבהובים. +## איטיות בהקלדה — חישוב סגנון של כל המסמך בכל תו + +„כשאני מקליד, לוקח כמה שניות עד שהתו מופיע.” נמדד ב‑Chrome על ה‑`dist` הארוז, +במסמך של חמישה עמודים: על כל הקשה הדפדפן חישב סגנון מחדש (`UpdateLayoutTree`) +ל‑**כל** עץ המסמך — 2,748 אלמנטים, 60–115ms לחישוב — 42 פעמים ב‑50 הקשות, עם +53 long tasks. במסמך ריק זה לא קרה; במסמך אמיתי, על מכונה עמוסה ובתוך ה‑WebView +של אוצריא, זה מה שמצטבר ל„כמה שניות”. + +המקור לא היה בקוד שרץ בהקלדה (פחות מאחוז מהזמן היה שלנו) אלא בכלל CSS יחיד +ב‑[src/styles/engine-chrome.css](src/styles/engine-chrome.css): ההסתרה של באנר +`edit-rejected` של המנוע נכתבה כ‑`.superdoc__mutation-status:has([data-superdoc-v2-edit-rejected])`. +`:has()` שהעוגן שלו יושב **בתוך** `.superdoc` גורם ל‑Blink לסמן את `.superdoc` +כמושפע מ‑`:has()`, ומאותו רגע כל הוספה או הסרה של צומת במסמך — כל תו — מתזמנת +חישוב סגנון של תת‑העץ כולו (ב‑trace: „Affected by :has()” על `DIV.superdoc`, +„Invalidation set invalidates subtree”). ההכרעה הייתה ניסוי מבוקר על אותו +מסמך: מחיקת הכלל הזה בלבד, בזמן ריצה, הורידה את החישובים המלאים ל‑0 ואת ה‑long +tasks ל‑3; מחיקת כלל אקראי אחר, או של ה‑`:has()` שברצועה, לא שינתה דבר. + +התיקון מסתיר את ה‑`

` שנושא את התכונה כילד ישיר של העוטף, בלי `:has()` — +העוטף הוא `height: 0` בלי ריפוד, ואין הבדל נראה. שני שערים שומרים שזה לא +יחזור: [tests/unit/css-hygiene.test.ts](tests/unit/css-hygiene.test.ts) חוסם כל +`:has()` שלא אושר במפורש עם נימוק, ו‑`npm run check:typing-recalc` +([scripts/qa/typing-style-recalc-qa.mjs](scripts/qa/typing-style-recalc-qa.mjs)) +מקליד במסמך של כמה עמודים תחת trace של ה‑renderer וסופר חישובי סגנון בגודל +המסמך כולו — הוא אדום על הכלל הישן (112 חישובים ב‑40 הקשות) וירוק אחרי התיקון. +לאבחון ידני יש [scripts/typing-latency-probe.mjs](scripts/typing-latency-probe.mjs): +זמן מלחיצה עד ציור, long tasks, והודעות קונסולה לכל הקשה — גם על התוסף כשהוא +רץ **בתוך אוצריא** (`--attach`, אחרי הרצת אוצריא עם +`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9444`). + +### בתוך אוצריא: מה נמדד אחרי התיקון, ומה נשאר למארח + +על המסמך של המשתמש (חמישה עמודים, שני טורים, הערות שוליים), בתוך ה‑WebView של +אוצריא, עם הקשות **אמיתיות** של מערכת ההפעלה: ההקשה מגיעה לדף תוך כמה +מילישניות, הדף מצייר אותה בלי long tasks ובלי חישוב סגנון של המסמך כולו, ולא +נמצא שום סטול. אבל הפיקסלים מופיעים על המסך רק כ‑150ms **אחרי** שה‑renderer +צייר אותם — זה שלב המארח: WebView2 מצייר ל‑visual, אוצריא לוכדת אותו +ב‑Windows.Graphics.Capture, מעבירה כטקסטורה ל‑Flutter, ו‑Flutter (בבניית Debug, +שבה נמדד) מצייר את הפריים. גם ריחוף על כפתור ברשת הניווט של אוצריא עצמה לקח +כ‑70ms עד המסך. זה אינו בתוסף ואינו במנוע, ואי אפשר לראותו ממדידה בתוך הדף. + +שני מצבים נראים כ„הקלדה איטית” ואינם כאלה: חלון ממוזער, או WebView שאוצריא +השהתה (`pause` → `IsVisible(false)`), הופכים את הדף ל‑`hidden` — rAF לא רץ +וטיימרים מוגבלים, וכל הקשה מחכה שניות. ופוקוס JS על משטח ההקלדה אינו פוקוס +המקלדת של המערכת: בלי לחיצת עכבר אמיתית ההקשות נופלות ב‑Flutter ולא בדף. +[scripts/typing-latency-inapp.mjs](scripts/typing-latency-inapp.mjs) מודד את +כל השרשרת — הקשה אמיתית → keydown → התו ב‑DOM → ציור → פיקסלים על המסך +(`--screen`) — ומדפיס את מצב הנראות והפוקוס לפני שהוא מתחיל. `--target` +בוחר איפה להקליד (כותרת ברוחב העמוד, טור שמאלי/ימני, או שורה לפי טקסט). + +**מה שנמדד תלוי בפסקה, לא בטורים.** התחושה „בטורים זה איטי” לא אושרה: פסקה +קצרה עם ריצת עיצוב אחת בתוך הטורים מקלידה מהר כמו הכותרת, גם בקצב של עשר +הקשות בשנייה. מה שכן איטי הוא פסקה שיש בתוכה ירידת שורה (`w:br`), טאב או +הפניה להערת שוליים: המנוע מסווג אותה כ‑`paragraph-complex-inline`, משליך אותה +מחדש מה‑XML בכל הקשה, והמחיר להקשה גדול בערך פי שניים. מספר ריצות העיצוב, +`jc=distribute`, ריווח אותיות וסגנון תו אינם משנים (ביסקציה על מסמכים סינתטיים; +דווח ל‑SuperDoc ב‑[issue #3984](https://github.com/superdoc/docx-editor/issues/3984)). +המנוע מעבד הקשה אחת בכל פעם ואינו מאחד הקשות שנערמו, ולכן במחשב עמוס הקלדה +מהירה בפסקה כזאת נערמת לתור. הסקריפט מדפיס בדיוק את המדד הזה („ההקשה האחרונה → +התו האחרון ב‑DOM”); מדוד אותו עם `--gap 100`. במחשב שקט (שתי ליבות, בניית +Release של אוצריא) העורך עומד בקצב גם בפסקה כזאת — התו האחרון מופיע חלקיק +שנייה אחרי ההקשה האחרונה, כמו באותו מסמך ב‑Chrome וב‑Edge. מדידה קודמת שהראתה +„יותר משנייה” רצה בזמן שסשן אחר בנה את אוצריא והריץ חבילת בדיקות באותו מחשב; +לכן הסקריפט מדפיס גם את ה‑CPU של שאר התהליכים בזמן הפרץ ומזהיר כשהוא גדול. +שימו לב שהמוטציה הראשונה אחרי הקשה היא שכבת הסמן ולא התו — מדידה שנעצרת בה +מדווחת „מהר” גם כשהתו מגיע מאוחר. המחיר להקשה יושב במנוע +(`@superdoc/docx-engine`), לא בתוסף ולא במארח; מספריו אינם מתפרסמים כאן. + ## מצב מיקוד `F11`, הכפתור ב„תצוגה” או הכפתור בשורת המצב — ו‑`Escape`, `F11` או כפתור diff --git a/docs/engine-gaps.md b/docs/engine-gaps.md index ee4cfef7..9525be22 100644 --- a/docs/engine-gaps.md +++ b/docs/engine-gaps.md @@ -1569,3 +1569,20 @@ wrap: { success: false, code: "INVALID_TARGET" } הוספת קישור כשהסמן כבר בתוך קישור קיים נכשלת בקוד הזה, עם הודעה באנגלית מן המנוע. `at-mention-overlay.ts` מתרגם אותו להודעה בעברית ואינו מנסה לעקוף. + +## הקלדה — `:has()` בגיליון המארח מחשב סגנון לכל המסמך בכל תו + +נמדד ב-9.2026 על ה-`dist` הארוז (Chrome, trace של ה-renderer עם +`invalidationTracking`): כלל CSS **שלנו** עם `:has()` שעוגנו ישב בתוך `.superdoc` +גרם לחישוב סגנון מחדש של כל עץ המסמך על כל הקשה. הסיפור, המספרים והתיקון — +בראש README („איטיות בהקלדה”) ובהערת הכלל ב-src/styles/engine-chrome.css. + +מה שנוגע למנוע, ולכן נרשם כאן: גיליון המנוע עצמו משתמש ב-`:has()`, ולכן קבוצת +האינבלידציה של `:has` בדף אינה שלנו לבד. בלי `:has()` שלנו בתוך `.superdoc` +התופעה אינה מופיעה (נמדד: מחיקת כללי המנוע לבדם ומחיקת הכלל שלנו לבדו נתנו +שתיהן 0), ולכן ההגנה היא בצד המארח: אין `:has()` שעוגנו יכול להיות אב של +צומת במסמך. tests/unit/css-hygiene.test.ts אוכף זאת סטטית, ו- +`npm run check:typing-recalc` מודד זאת בדפדפן. + +לא נשלח למעלה: הגיליון הזה הוא של `@superdoc/docx-engine` הקנייני, והרישיון שלו +אוסר לפרסם מדידות של המנוע. אם יידווח — כתיאור איכותי בלבד, בלי מספרים. diff --git a/package.json b/package.json index 71334a68..dc229d1f 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,9 @@ "check:open-dialog": "node scripts/open-dialog-probe.mjs", "check:focus": "node scripts/qa/focus-mode-qa.mjs", "check:font-list": "node scripts/qa/font-list-layout-qa.mjs", + "check:typing-recalc": "node scripts/qa/typing-style-recalc-qa.mjs", "verify": "npm run typecheck && npm run test && npm run build && npm run check:dist && npm run check:boot && npm run check:fonts && npm run check:icons && npm run check:rtl && npm run check:ruler && npm run check:startup && npm run check:gutter && npm run check:readout && npm run check:tooltip && npm run check:ribbon && npm run check:session && npm run check:open-dialog && npm run check:menu && npm run check:sdk", - "verify:qa": "npm run build && node scripts/qa/run-all.mjs scripts/qa/smoke.mjs scripts/qa/hebrew-numbering-probe.mjs scripts/qa/hebrew-list-probe.mjs scripts/qa/hebrew-numbering-ui-qa.mjs scripts/qa/replace-all-probe.mjs scripts/qa/replace-race-probe.mjs scripts/qa/replace-ui-probe.mjs scripts/qa/replace-all-multiparagraph-qa.mjs scripts/qa/find-replace-navigation-qa.mjs scripts/qa/replace-all-undo-qa.mjs scripts/qa/replace-buttons-reenable-qa.mjs scripts/qa/export-pdf-qa.mjs scripts/qa/file-ops-qa.mjs scripts/qa/file-freeze-qa.mjs scripts/qa/installed-fonts-qa.mjs scripts/qa/load-progress-qa.mjs scripts/qa/multi-doc-qa.mjs scripts/qa/spellcheck-qa.mjs scripts/qa/bold-cs-qa.mjs scripts/qa/list-resolver-qa.mjs scripts/qa/focus-mode-qa.mjs scripts/qa/context-font-focus-probe.mjs scripts/qa/list-caret-qa.mjs scripts/qa/font-caret-qa.mjs scripts/qa/at-mention-qa.mjs scripts/qa/book-completion-qa.mjs scripts/qa/acronym-completion-qa.mjs scripts/qa/static-completion-qa.mjs scripts/qa/font-sample-qa.mjs scripts/qa/font-availability-qa.mjs scripts/qa/font-list-layout-qa.mjs scripts/qa/tell-me-qa.mjs", + "verify:qa": "npm run build && node scripts/qa/run-all.mjs scripts/qa/smoke.mjs scripts/qa/hebrew-numbering-probe.mjs scripts/qa/hebrew-list-probe.mjs scripts/qa/hebrew-numbering-ui-qa.mjs scripts/qa/replace-all-probe.mjs scripts/qa/replace-race-probe.mjs scripts/qa/replace-ui-probe.mjs scripts/qa/replace-all-multiparagraph-qa.mjs scripts/qa/find-replace-navigation-qa.mjs scripts/qa/replace-all-undo-qa.mjs scripts/qa/replace-buttons-reenable-qa.mjs scripts/qa/export-pdf-qa.mjs scripts/qa/file-ops-qa.mjs scripts/qa/file-freeze-qa.mjs scripts/qa/installed-fonts-qa.mjs scripts/qa/load-progress-qa.mjs scripts/qa/multi-doc-qa.mjs scripts/qa/spellcheck-qa.mjs scripts/qa/bold-cs-qa.mjs scripts/qa/list-resolver-qa.mjs scripts/qa/focus-mode-qa.mjs scripts/qa/context-font-focus-probe.mjs scripts/qa/list-caret-qa.mjs scripts/qa/font-caret-qa.mjs scripts/qa/at-mention-qa.mjs scripts/qa/book-completion-qa.mjs scripts/qa/acronym-completion-qa.mjs scripts/qa/static-completion-qa.mjs scripts/qa/font-sample-qa.mjs scripts/qa/font-availability-qa.mjs scripts/qa/font-list-layout-qa.mjs scripts/qa/tell-me-qa.mjs scripts/qa/typing-style-recalc-qa.mjs", "postverify:qa": "node scripts/qa/click-snap-qa.mjs", "verify:fast": "npm run typecheck && npm run test && npm run build && npm run check:dist", "golden:capture": "npm run build && node scripts/qa/golden.mjs capture scripts/qa/smoke.mjs scripts/qa/hebrew-numbering-probe.mjs scripts/qa/hebrew-list-probe.mjs scripts/qa/hebrew-numbering-ui-qa.mjs scripts/qa/replace-all-probe.mjs scripts/qa/replace-race-probe.mjs scripts/qa/replace-ui-probe.mjs scripts/qa/replace-all-multiparagraph-qa.mjs scripts/qa/find-replace-navigation-qa.mjs scripts/qa/replace-all-undo-qa.mjs scripts/qa/replace-buttons-reenable-qa.mjs scripts/qa/export-pdf-qa.mjs scripts/qa/file-ops-qa.mjs scripts/qa/file-freeze-qa.mjs scripts/qa/installed-fonts-qa.mjs scripts/qa/load-progress-qa.mjs scripts/qa/spellcheck-qa.mjs scripts/qa/bold-cs-qa.mjs scripts/qa/page-border-overlay-qa.mjs scripts/qa/line-number-overlay-qa.mjs scripts/qa/pilcrow-overlay-qa.mjs", diff --git a/scripts/qa/typing-style-recalc-qa.mjs b/scripts/qa/typing-style-recalc-qa.mjs new file mode 100644 index 00000000..f277a180 --- /dev/null +++ b/scripts/qa/typing-style-recalc-qa.mjs @@ -0,0 +1,158 @@ +/** + * שער QA: הקלדה אינה מחשבת מחדש את הסגנון של כל עץ המסמך. + * + * מה שנמדד, ולמה זה השער: ב-9.2026 דווח „לוקח כמה שניות עד שהתו מופיע”. + * ה-trace של Chrome הראה שעל כל הקשה Blink חישב סגנון מחדש (`UpdateLayoutTree`) + * ל-**כל** עץ המסמך — 2,748 אלמנטים במסמך של חמישה עמודים, 60–115ms לחישוב, + * 42 חישובים ב-50 הקשות — בגלל כלל `:has()` יחיד שלנו שעוגנו ישב בתוך + * `.superdoc` (engine-chrome.css, באנר `edit-rejected`). אחרי התיקון: 0. + * ההסבר המלא ליד הכלל, וגדר סטטית ב-tests/unit/css-hygiene.test.ts. + * + * השער הזה הוא הגדר הדינמית: הוא מקליד במסמך של כמה עמודים תחת trace של + * ה-renderer, וסופר חישובי סגנון שגודלם הוא סדר-הגודל של המסמך כולו. הוא + * תופס גם מה שהסטטי אינו רואה — כלל ב-`.vue` שנוסח אחרת, או שדרוג מנוע שמשנה + * את גיליון המנוע — כי הוא מודד את התוצאה ולא את המקור. + * + * למה ספירה ולא זמן: המכונה רועשת (ראו scripts/cdp.mjs) וזמן משתנה פי כמה בין + * ריצות; מספר האלמנטים בחישוב הוא תכונה של ה-DOM וה-CSS, ואינו תלוי בעומס. + * הסף (`WHOLE_TREE_ELEMENTS`) הוא מחצית ממה שנמדד למסמך של חמישה עמודים, + * ורחוק בשני סדרי גודל מחישוב של הקשה תקינה (חציון 7 אלמנטים). + * + * הרצה: node scripts/qa/typing-style-recalc-qa.mjs (יציאה 9391) + */ +import { openApp, createReport, sleep } from './harness.mjs'; + +const PORT = Number(process.env.QA_PORT ?? 9391); +const report = createReport('הקלדה — אין חישוב סגנון של כל המסמך', { strict: true }); + +/** חישוב שנוגע ביותר מזה נחשב „כל המסמך”. */ +const WHOLE_TREE_ELEMENTS = 1000; +/** כמה חישובים כאלה מותרים במהלך ההקלדה. אפס נמדד אחרי התיקון; אחד — רעש. */ +const MAX_WHOLE_TREE_RECALCS = 1; +const KEYSTROKES = 40; +const MIN_PAGES = 3; + +/** + * חיבור CDP שני, לאותו דף, שיודע לקבל **אירועים**: `Tracing.dataCollected` + * מגיע כאירוע, ו-`scripts/cdp.mjs` מטפל רק בתשובות לפי id. CDP מרשה כמה + * לקוחות לאותו יעד, ולכן זה אינו משבש את ההרצה עצמה. + */ +async function attachTracer(port, urlPart) { + const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + const page = list.find((t) => t.type === 'page' && t.url.includes(urlPart)); + if (!page) throw new Error('דף השער לא נמצא ברשימת היעדים'); + const socket = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', () => reject(new Error('חיבור ה-trace נכשל')), { once: true }); + }); + let nextId = 0; + const pending = new Map(); + const collected = []; + let complete = null; + socket.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (message.id === undefined) { + if (message.method === 'Tracing.dataCollected') collected.push(...message.params.value); + if (message.method === 'Tracing.tracingComplete') complete?.(); + return; + } + const settle = pending.get(message.id); + if (settle) { + pending.delete(message.id); + settle(message); + } + }); + const send = (method, params) => + new Promise((resolve) => { + const id = ++nextId; + pending.set(id, resolve); + socket.send(JSON.stringify({ id, method, params })); + }); + return { + start: () => + send('Tracing.start', { + categories: 'devtools.timeline,disabled-by-default-devtools.timeline,blink.user_timing', + transferMode: 'ReportEvents', + }), + async stop() { + const done = new Promise((resolve) => { + complete = resolve; + }); + await send('Tracing.end'); + await Promise.race([done, sleep(60_000)]); + socket.close(); + return collected; + }, + }; +} + +const app = await openApp({ name: 'typing-style-recalc', port: PORT }); + +try { + await app.cdp.send('Emulation.setDeviceMetricsOverride', { width: 1400, height: 900, deviceScaleFactor: 1, mobile: false }); + await sleep(800); + await app.caret(0); + + /* ---- מסמך של כמה עמודים: הכשל תלוי בגודל העץ, ומסמך ריק אינו מודד אותו ---- + * דרך ה-Document API ולא בהקלדה: זה תנאי המדידה, לא מה שנמדד (כמו + * column-selection-probe.mjs). 90 פסקאות של ~130 תווים הן כ-5 עמודים. */ + await app.js(`(async () => { + const doc = window.__otzariaEditor.superdoc.activeEditor.doc; + const filler = 'בראשית ברא אלהים את השמים ואת הארץ והארץ היתה תהו ובהו וחשך על פני תהום ורוח אלהים מרחפת על פני המים ויאמר אלהים יהי אור'; + const paras = []; + for (let i = 1; i <= 90; i++) paras.push('פסקה ' + i + ' — ' + filler); + await doc.insert({ value: paras.join('\\n\\n'), type: 'markdown' }); + })()`); + await sleep(3000); + await app.caret(2); + const pages = Number(await app.js('document.querySelectorAll("[data-page-index]").length')); + const elements = Number(await app.js('window.__otzariaEditor.container.querySelectorAll("*").length')); + report[pages >= MIN_PAGES ? 'pass' : 'fail']( + 'מסמך הבדיקה גדול דיו', + `${pages} עמודים, ${elements} אלמנטים בעץ המסמך`, + pages >= MIN_PAGES ? '' : `נדרשים לפחות ${MIN_PAGES} עמודים כדי שהמדידה תהיה משמעותית`, + ); + + /* ---- הקלדה תחת trace ---- */ + const tracer = await attachTracer(PORT, '__qa-typing-style-recalc'); + await tracer.start(); + await sleep(300); + await app.js('performance.mark("qa:typing-start")'); + await app.type('אבגדהוזחטיכלמנסעפצקרשת אבגדהוזחטיכלמנסעפצקרשת'.slice(0, KEYSTROKES), 80); + await sleep(600); + await app.js('performance.mark("qa:typing-end")'); + await sleep(300); + const events = await tracer.stop(); + + const mark = (name) => events.find((e) => e.name === name && e.cat?.includes('blink.user_timing'))?.ts; + const from = mark('qa:typing-start'); + const to = mark('qa:typing-end'); + if (from === undefined || to === undefined) { + report.fail('ה-trace נאסף', `אירועים: ${events.length}`, 'סימני ההתחלה/סיום של ההקלדה לא נמצאו ב-trace'); + } else { + const recalcs = events.filter((e) => e.name === 'UpdateLayoutTree' && e.ph === 'X' && e.ts >= from && e.ts <= to); + const counts = recalcs.map((e) => e.args?.elementCount ?? 0).sort((a, b) => a - b); + const whole = recalcs.filter((e) => (e.args?.elementCount ?? 0) >= WHOLE_TREE_ELEMENTS); + const wholeMs = Math.round(whole.reduce((sum, e) => sum + (e.dur ?? 0), 0) / 1000); + const median = counts.length ? counts[Math.floor(counts.length / 2)] : 0; + const max = counts.length ? counts[counts.length - 1] : 0; + + report[recalcs.length > 0 ? 'pass' : 'fail']( + 'ההקלדה אכן גרמה לחישובי סגנון (המדידה חיה)', + `${recalcs.length} חישובים ב-${KEYSTROKES} הקשות; חציון ${median} אלמנטים, מקסימום ${max}`, + recalcs.length > 0 ? '' : 'אפס חישובים — ההקלדה לא הגיעה למסמך, או שה-trace ריק', + ); + report[whole.length <= MAX_WHOLE_TREE_RECALCS ? 'pass' : 'fail']( + `אין חישוב סגנון של כל המסמך (≥ ${WHOLE_TREE_ELEMENTS} אלמנטים) בזמן הקלדה`, + `${whole.length} חישובים כאלה, ${wholeMs}ms יחד`, + whole.length <= MAX_WHOLE_TREE_RECALCS + ? '' + : 'כל הקשה מחשבת מחדש את כל עץ המסמך — ראו engine-chrome.css, הערת הכלל של באנר edit-rejected', + ); + } +} finally { + app.close(); +} + +process.exit(report.print() > 0 ? 1 : 0); diff --git a/scripts/typing-latency-inapp.mjs b/scripts/typing-latency-inapp.mjs new file mode 100644 index 00000000..fea0209d --- /dev/null +++ b/scripts/typing-latency-inapp.mjs @@ -0,0 +1,416 @@ +/** + * מדידת הקלדה **בתוך אוצריא**, מקצה לקצה: הקשה אמיתית של מערכת ההפעלה → keydown בדף → + * ציור ב-renderer → פיקסלים על המסך. משלים את typing-latency-probe.mjs (--attach), שמודד + * רק מה שקורה בתוך הדף ובמקשים סינתטיים של CDP. + * + * למה צריך את זה: הדף יכול לצייר תוך 30ms ובכל זאת התו יופיע למשתמש מאוחר — כי המארח + * (WebView2 → Windows.Graphics.Capture → טקסטורה של Flutter → raster) מוסיף שלב משלו, + * וכי חלון ממוזער או WebView מושהה הופכים את הדף ל-`hidden` (rAF לא רץ). שני אלה + * אינם נראים במדידה דרך CDP בלבד. + * + * הכנה: להריץ את אוצריא עם WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9444, + * לפתוח את התוסף עם מסמך, ולא לגעת במחשב בזמן הריצה (הכלי מביא את החלון לחזית). + * ההקשות נכנסות למסמך הפעיל — לא להריץ על מסמך שאין לו גיבוי. + * + * אפשרויות: + * --port יציאת CDP של WebView2 (9444) + * --keys מספר הקשות (40); 0 = רק חיבור, מכשור ובדיקת מצב (בלי קלט) + * --gap מרווח בין הקשות (80; עם --screen לפחות 350) + * --screen גם דגימת המסך: כמה זמן עד שהפיקסלים בשורת הסמן משתנים + * --no-click בלי לחיצת עכבר אמיתית לפני ההקלדה (ברירת המחדל לוחצת על שורת + * טקסט גלויה — פוקוס JS בלבד אינו מעביר את פוקוס המקלדת של + * המערכת אל חלון ה-WebView, וההקשות נופלות ב-Flutter) + * --target <מה> איזו שורה ללחוץ: auto (השורה הגלויה השלישית), wide (שורה ברוחב + * העמוד — כותרת או מקטע של טור אחד), left / right (שורה בטור + * השמאלי/הימני של מקטע טורים; ב-SuperDoc השמאלי הוא הראשון בסדר + * המסמך), text:<קטע> (שורה שמכילה את הטקסט). שורה שאינה גלויה + * מגוללת אל המרכז. הדוח מציין מה נלחץ. + * + * הרצה: node scripts/typing-latency-inapp.mjs --screen + */ +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const args = process.argv.slice(2); +const opt = (name, dflt) => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : dflt; +}; +const PORT = Number(opt('--port', 9444)); +const KEYS = Number(opt('--keys', 40)); +const SCREEN = args.includes('--screen'); +const GAP_MS = Math.max(Number(opt('--gap', 80)), SCREEN ? 350 : 0); +const CLICK = !args.includes('--no-click'); +const TARGET = opt('--target', 'auto'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/* ------------------------------ CDP ------------------------------ */ +const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`).catch(() => null))?.json?.() ?? null; +if (!list) { + console.error(`אין CDP ב-127.0.0.1:${PORT}. להריץ את אוצריא עם WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=${PORT}`); + process.exit(1); +} +const page = list.find((t) => t.type === 'page' && /otzaria-word-editor\/dist\/index\.html/.test(t.url)); +if (!page) { + console.error('דף התוסף לא נמצא. דפים: ' + list.map((t) => `${t.type} ${t.url}`).join(' | ')); + process.exit(1); +} +const ws = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }); + ws.addEventListener('error', () => reject(new Error('חיבור CDP נכשל')), { once: true }); +}); +let nextId = 0; +const pending = new Map(); +ws.addEventListener('message', (e) => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + pending.get(m.id)(m); + pending.delete(m.id); + } +}); +const send = (method, params) => + new Promise((resolve) => { + const id = ++nextId; + pending.set(id, resolve); + ws.send(JSON.stringify({ id, method, params })); + }); +const js = async (expression) => { + const m = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + if (m.result?.exceptionDetails) throw new Error('CDP: ' + (m.result.exceptionDetails.exception?.description ?? m.result.exceptionDetails.text)); + return m.result?.result?.value; +}; + +/* ------------------------------ החלון ------------------------------ */ +// חלון ממוזער = דף `hidden`; משחזרים אותו לפני בדיקת המצב, כמו שהמשתמש היה עושה. +const hwndOf = () => { + const r = spawnSync('powershell', ['-NoProfile', '-Command', '(Get-Process otzaria -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).MainWindowHandle'], { encoding: 'utf8' }); + const n = Number((r.stdout || '').trim()); + return Number.isFinite(n) && n > 0 ? n : 0; +}; +const HWND = Number(opt('--hwnd', 0)) || hwndOf(); +if (!HWND) { + console.error('לא נמצא חלון של otzaria.exe'); + ws.close(); + process.exit(2); +} +{ + const r = spawnSync('powershell', ['-NoProfile', '-Command', `Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class Wn { [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); }'; $h=[IntPtr]${HWND}; if ([Wn]::IsIconic($h)) { [void][Wn]::ShowWindow($h, 9); Start-Sleep -Milliseconds 400; 'restored' } else { 'shown' }`], { encoding: 'utf8' }); + const word = (r.stdout || '').trim(); + if (word === 'restored') { + console.log('החלון היה ממוזער — שוחזר.'); + await sleep(800); + } +} + +/* ------------------------------ מצב הדף ------------------------------ */ +const state = JSON.parse( + await js(`JSON.stringify({ + vis: document.visibilityState, focus: document.hasFocus(), + active: document.activeElement ? document.activeElement.tagName : null, + tab: ((document.querySelector('.word-doctab.active') || {}).textContent || '').trim(), + inner: [innerWidth, innerHeight], screen: [screenX, screenY], + text: window.__otzariaEditor ? window.__otzariaEditor.container.textContent.length : -1, + pages: document.querySelectorAll('[data-page-index]').length, + })`), +); +console.log(`דף: ${page.url}`); +console.log(`מצב: נראות ${state.vis}, פוקוס ${state.focus}, לשונית „${state.tab}”, ${state.pages} עמודים, ${state.text} תווים ב-DOM, חלון ${state.inner.join('×')} במסך (${state.screen.join(',')})`); +if (state.vis !== 'visible') { + console.error('הדף `hidden` — חלון ממוזער או WebView מושהה. במצב הזה rAF לא רץ והמדידה תראה „הקלדה איטית” שאינה כזו. לשחזר את החלון ולהריץ שוב.'); + ws.close(); + process.exit(2); +} +if (state.text < 0) { + console.error('אין עורך פתוח (window.__otzariaEditor). לפתוח מסמך בתוסף ולהריץ שוב.'); + ws.close(); + process.exit(2); +} + +/* ------------------------------ מכשור בדף ------------------------------ */ +await js(`(function () { + delete window.__inapp; + var O = (window.__inapp = { keys: [], text: [], long: [], vis: [] }); + var epoch = function (t) { return performance.timeOrigin + t; }; + var c = window.__otzariaEditor.container; + var last = c.textContent; + document.addEventListener('keydown', function (e) { + O.keys.push({ key: e.key, down: epoch(performance.now()) }); + }, true); + // המוטציה הראשונה אחרי הקשה היא בדרך כלל שכבת הסמן/הבחירה, לא התו (נמדד: היא + // מגיעה ~10ms אחרי keydown, והתו עצמו ~50–150ms אחריו). לכן נרשם רק שינוי בטקסט + // של המסמך, וה-rAF הראשון אחריו הוא הציור. הרישום הוא סדרה, לא „ההקשה האחרונה”: + // בהקלדה מהירה התו של הקשה N מגיע אחרי keydown של N+1. + new MutationObserver(function () { + var t = c.textContent; if (t === last) return; + var rec = { t: epoch(performance.now()), d: t.length - last.length, painted: null }; + last = t; O.text.push(rec); + requestAnimationFrame(function () { rec.painted = epoch(performance.now()); }); + }).observe(c, { childList: true, subtree: true, characterData: true, attributes: true }); + document.addEventListener('visibilitychange', function () { O.vis.push({ t: Date.now(), vis: document.visibilityState }); }); + try { + new PerformanceObserver(function (l) { l.getEntries().forEach(function (en) { O.long.push({ start: epoch(en.startTime), dur: Math.round(en.duration) }); }); }).observe({ entryTypes: ['longtask'] }); + } catch (e) { O.longError = String(e); } + return 'ok'; +})()`); + +/* ------------------------------ PowerShell: קלט אמיתי ומסך ------------------------------ */ +// שני שלבים: (1) לחיצת עכבר על שורת טקסט גלויה, כדי שפוקוס המקלדת של המערכת יעבור לחלון +// ה-WebView (Chrome_WidgetWin_0); (2) הקשות SendInput ב-KEYEVENTF_UNICODE עם חותמת זמן +// לכל הקשה, ועם --screen גם דגימת המסך עד שינוי בשורת הסמן (סף 60 פיקסלים: אות עברית +// קטנה משנה כ-100, הבהוב הסמן כ-30 והוא מסונן בבסיס הכפול). INPUT הוא 40 בתים ב-x64 +// (union של 32) — גודל שגוי מחזיר 0 עם ERROR_INVALID_PARAMETER ואין קלט בכלל. +const PS = String.raw` +param([string]$Mode, [int]$Hwnd, [int]$X, [int]$Y, [int]$Count, [int]$GapMs, [int]$RX, [int]$RY, [int]$RW, [int]$RH, [string]$Out) +Add-Type -AssemblyName System.Drawing +Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @" +using System; using System.Runtime.InteropServices; using System.Text; +public static class N { + [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + [StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + [StructLayout(LayoutKind.Explicit)] public struct INPUTUNION { [FieldOffset(0)] public KEYBDINPUT ki; [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public long p0; [FieldOffset(8)] public long p1; [FieldOffset(16)] public long p2; [FieldOffset(24)] public long p3; } + [StructLayout(LayoutKind.Sequential)] public struct INPUT { public uint type; public INPUTUNION u; } + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; } + [StructLayout(LayoutKind.Sequential)] public struct GUITHREADINFO { public uint cbSize; public uint flags; public IntPtr hwndActive; public IntPtr hwndFocus; public IntPtr hwndCapture; public IntPtr hwndMenuOwner; public IntPtr hwndMoveSize; public IntPtr hwndCaret; public RECT rcCaret; } + [DllImport("user32.dll", SetLastError=true)] public static extern uint SendInput(uint n, INPUT[] inputs, int size); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); + [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] public static extern bool GetGUIThreadInfo(uint tid, ref GUITHREADINFO info); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder s, int n); + static int Size() { return Marshal.SizeOf(typeof(INPUT)); } + public static string SendChar(char c) { + var d = new INPUT(); d.type = 1; d.u.ki.wScan = c; d.u.ki.dwFlags = 0x0004; + var u = new INPUT(); u.type = 1; u.u.ki.wScan = c; u.u.ki.dwFlags = 0x0006; + uint n = SendInput(2, new INPUT[] { d, u }, Size()); + return n == 2 ? "ok" : ("SendInput=" + n + " err=" + Marshal.GetLastWin32Error()); + } + public static string Click(int x, int y) { + SetCursorPos(x, y); System.Threading.Thread.Sleep(80); + var d = new INPUT(); d.type = 0; d.u.mi.dwFlags = 0x0002; var u = new INPUT(); u.type = 0; u.u.mi.dwFlags = 0x0004; + uint n = SendInput(1, new INPUT[] { d }, Size()); System.Threading.Thread.Sleep(40); n += SendInput(1, new INPUT[] { u }, Size()); + return n == 2 ? "ok" : ("SendInput=" + n + " err=" + Marshal.GetLastWin32Error()); + } + public static string FocusClass() { + IntPtr fg = GetForegroundWindow(); uint pid; uint tid = GetWindowThreadProcessId(fg, out pid); + var g = new GUITHREADINFO(); g.cbSize = (uint)Marshal.SizeOf(typeof(GUITHREADINFO)); + if (!GetGUIThreadInfo(tid, ref g)) return "?"; + var sb = new StringBuilder(256); GetClassNameW(g.hwndFocus, sb, 256); return sb.ToString(); + } + public static int Diff(System.Drawing.Bitmap a, System.Drawing.Bitmap b) { + var rect = new System.Drawing.Rectangle(0, 0, a.Width, a.Height); + var ra = a.LockBits(rect, System.Drawing.Imaging.ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + var rb = b.LockBits(rect, System.Drawing.Imaging.ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + int n = a.Width * a.Height; int[] pa = new int[n]; int[] pb = new int[n]; + Marshal.Copy(ra.Scan0, pa, 0, n); Marshal.Copy(rb.Scan0, pb, 0, n); + a.UnlockBits(ra); b.UnlockBits(rb); + int d = 0; for (int i = 0; i < n; i++) if (pa[i] != pb[i]) d++; return d; + } +} +"@ +function Grab { $bmp = New-Object System.Drawing.Bitmap $RW, $RH, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb); $g = [System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($RX, $RY, 0, 0, (New-Object System.Drawing.Size $RW, $RH)); $g.Dispose(); return $bmp } +$h = [IntPtr]$Hwnd +if ([N]::IsIconic($h)) { [void][N]::ShowWindow($h, 9); Start-Sleep -Milliseconds 300 } +[void][N]::SetForegroundWindow($h); Start-Sleep -Milliseconds 400 +$result = @{ fg = [int64][N]::GetForegroundWindow(); focusBefore = [N]::FocusClass() } +if ($Mode -eq 'click') { + $result.click = [N]::Click($X, $Y); Start-Sleep -Milliseconds 400 + $result.focusAfter = [N]::FocusClass() +} else { + $letters = -join (0x05D0..0x05EA | ForEach-Object { [char]$_ }) + $keys = New-Object System.Collections.ArrayList + # CPU של כל התהליכים במחשב לפני ואחרי הפרץ: בנייה או חבילת בדיקות של סשן אחר על שתי ליבות + # מכפילה את עלות ההקשה, ובלי השורה הזאת המדידה נראית תקינה. מודדים לפי TotalProcessorTime. + function CpuSnap { $h = @{}; Get-Process -ErrorAction SilentlyContinue | ForEach-Object { try { $h[$_.Id] = @($_.Name, $_.TotalProcessorTime.TotalMilliseconds) } catch {} }; return $h } + $cpu0 = CpuSnap; $cpuT0 = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $sw = [System.Diagnostics.Stopwatch]::StartNew() + for ($i = 0; $i -lt $Count; $i++) { + $c = $letters[$i % $letters.Length] + $base = $null + if ($RW -gt 0) { $base = Grab; Start-Sleep -Milliseconds 25; $b2 = Grab; if ([N]::Diff($base, $b2) -gt 0) { $base.Dispose(); $base = $b2 } else { $b2.Dispose() } } + $t0 = $sw.Elapsed.TotalMilliseconds + $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $sent = [N]::SendChar($c) + $seen = -1; $d = 0 + if ($base -ne $null) { + while (($sw.Elapsed.TotalMilliseconds - $t0) -lt 4000) { $cur = Grab; $d = [N]::Diff($base, $cur); $cur.Dispose(); if ($d -ge 60) { $seen = [Math]::Round($sw.Elapsed.TotalMilliseconds - $t0); break } } + $base.Dispose() + } + [void]$keys.Add(@{ i = $i; ch = [string]$c; t = $epoch; sent = $sent; screenMs = $seen; diff = $d }) + Start-Sleep -Milliseconds $GapMs + } + $result.keys = $keys + $result.focusAfter = [N]::FocusClass() + $cpu1 = CpuSnap; $cpuDt = [Math]::Max(1, [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $cpuT0) + $mine = @{}; $others = @{} + foreach ($id in $cpu1.Keys) { + if (-not $cpu0.ContainsKey($id)) { continue } + $nm = $cpu1[$id][0]; $pct = 100 * ($cpu1[$id][1] - $cpu0[$id][1]) / $cpuDt + if ($pct -le 0) { continue } + if ($nm -eq 'otzaria' -or $nm -eq 'msedgewebview2' -or $id -eq $PID) { $mine[$nm] = [double]($mine[$nm]) + $pct } else { $others[$nm] = [double]($others[$nm]) + $pct } + } + $top = $others.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 3 | ForEach-Object { @{ name = $_.Key; pct = [Math]::Round($_.Value) } } + $result.cpu = @{ logical = [Environment]::ProcessorCount; measured = [Math]::Round(($mine.Values | Measure-Object -Sum).Sum); others = [Math]::Round(($others.Values | Measure-Object -Sum).Sum); top = @($top) } +} +$result | ConvertTo-Json -Compress -Depth 4 | Out-File -Encoding utf8 $Out +`; + +const tmp = mkdtempSync(join(tmpdir(), 'owe-inapp-')); +const psPath = join(tmp, 'inapp.ps1'); +writeFileSync(psPath, '' + PS, 'utf8'); +const runPs = (params) => { + const out = join(tmp, `out-${Date.now()}.json`); + const argv = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', psPath, '-Out', out]; + for (const [k, v] of Object.entries(params)) argv.push(`-${k}`, String(v)); + const r = spawnSync('powershell', argv, { encoding: 'utf8', timeout: 600_000 }); + if (r.status !== 0) throw new Error('PowerShell נכשל: ' + (r.stderr || r.stdout).trim().slice(0, 600)); + return JSON.parse(readFileSync(out, 'utf8').replace(/^/, '')); +}; +let result = null; +try { + const hwnd = HWND; + if (KEYS === 0) { + console.log('--keys 0: מכשור הותקן, אין קלט. (hwnd ' + hwnd + ')'); + } else { + if (CLICK) { + // שורת טקסט גלויה, בתוך החלון (בחלון צר הדף רחב מהחלון והשורות חורגות ממנו). + // שורה בלי קישורים: לחיצה על קישור במסמך פותחת ספר באוצריא ומחליפה לשונית. + // סיווג השורה לפי רוחבה מול העמוד שלה: wide = מעל 60% מרוחב העמוד; אחרת טור, + // ימני או שמאלי לפי מרכז השורה מול מרכז העמוד. + const pt = JSON.parse( + await js(`(async function(target){var W=innerWidth,H=innerHeight;var all=[...document.querySelectorAll('.superdoc-line')].filter(function(l){return !l.querySelector('a,[href],[data-href],[data-link],[class*=link]');});var info=function(l){var r=l.getBoundingClientRect();var pg=l.closest('[data-page-index]');var pr=pg?pg.getBoundingClientRect():{left:0,right:W};var kind=r.width>(pr.right-pr.left)*0.6?'wide':((r.left+r.right)/2<(pr.left+pr.right)/2?'left':'right');var full=(l.textContent||'').trim();return {el:l,r:r,kind:kind,full:full,txt:full.slice(0,40)};};var vis=function(o){var r=o.r;return r.height>8&&r.top>120&&r.bottom80;};var match=function(o){return target==='auto'||(target.indexOf('text:')===0?o.full.indexOf(target.slice(5))>=0:o.kind===target);};var os=all.map(info);var cands=os.filter(match);var seen=cands.filter(vis);var pick=target==='auto'?seen[Math.min(seen.length-1,2)]:seen[0];if(!pick&&cands.length){cands[0].el.scrollIntoView({block:'center'});await new Promise(function(r){setTimeout(r,700)});var o=info(cands[0].el);if(vis(o))pick=o;}if(!pick)return JSON.stringify({error:cands.length?'not-visible':'no-match',kinds:os.reduce(function(m,o){m[o.kind]=(m[o.kind]||0)+1;return m;},{})});var r=pick.r;var left=Math.max(r.left,0),right=Math.min(r.right,W);return JSON.stringify({x:Math.round((left+right)/2),y:Math.round(r.top+r.height/2),kind:pick.kind,w:Math.round(r.width),txt:pick.txt});})(${JSON.stringify(TARGET)})`), + ); + if (!pt || pt.error) throw new Error(pt && pt.error === 'no-match' ? `אין שורה מסוג „${TARGET}” במסמך (יש: ${JSON.stringify(pt.kinds)})` : 'לא נמצאה שורת טקסט גלויה ללחיצה'); + console.log(`שורת היעד: ${pt.kind}, רוחב ${pt.w}px, „${pt.txt}”`); + const tabBefore = state.tab; + // מיקום החלון נקרא מחדש: אוצריא עשויה להזיז את החלון (שחזור מיקום) גם שניות אחרי הפתיחה. + state.screen = JSON.parse(await js('JSON.stringify([screenX, screenY])')); + const c = runPs({ Mode: 'click', Hwnd: hwnd, X: state.screen[0] + pt.x, Y: state.screen[1] + pt.y, Count: 0, GapMs: 0, RX: 0, RY: 0, RW: 0, RH: 0 }); + console.log(`לחיצה ב-(${state.screen[0] + pt.x},${state.screen[1] + pt.y}): ${c.click}; פוקוס המקלדת: ${c.focusBefore} → ${c.focusAfter}`); + // אחרי הלחיצה: העורך חייב להיות קיים ובאותה לשונית (החלפת לשונית מאפסת את __otzariaEditor לרגע). + let settled = false; + for (let i = 0; i < 20 && !settled; i++) { + await sleep(150); + settled = await js(`!!(window.__otzariaEditor && window.__otzariaEditor.container) && ((document.querySelector('.word-doctab.active') || {}).textContent || '').trim() === ${JSON.stringify(tabBefore)}`); + } + if (!settled) { + // הלחיצה החליפה לשונית (או שהעורך נעלם לרגע): חוזרים ללשונית המקורית דרך הדף. פוקוס + // המקלדת של המערכת כבר על חלון ה-WebView אחרי הלחיצה, ולכן די בפוקוס JS למשטח ההקלדה. + console.log('אחרי הלחיצה הלשונית/העורך לא במקום — חוזרים ללשונית „' + tabBefore + '”'); + await js(`(function(){var t=Array.prototype.find.call(document.querySelectorAll('.word-doctab'),function(x){return (x.textContent||'').trim()===${JSON.stringify(tabBefore)}}); if(t) t.click(); return !!t;})()`); + for (let i = 0; i < 30 && !settled; i++) { + await sleep(200); + settled = await js(`!!(window.__otzariaEditor && window.__otzariaEditor.container)`); + } + if (!settled) throw new Error('העורך לא חזר אחרי הלחיצה'); + await js(`(function(){var c=window.__otzariaEditor.container;var ta=c.querySelector('textarea, [contenteditable="true"]'); if(ta) ta.focus(); return document.activeElement&&document.activeElement.tagName;})()`); + } + await sleep(250); + } + let region = { RX: 0, RY: 0, RW: 0, RH: 0 }; + if (SCREEN) { + const caret = JSON.parse( + await js(`(function(){var c=window.__otzariaEditor.container;var e=c.querySelector('.sd-v2-local-selection-caret')||c.querySelector('textarea');if(!e)return 'null';var r=e.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x),y:Math.round(r.y),h:Math.round(r.height)});})()`), + ); + if (!caret || caret.h <= 0) throw new Error('הסמן לא נמצא — ללחוץ במסמך קודם (בלי --no-click)'); + // שלוש שורות מגובה הסמן, לרוחב החלון: הסמן יורד שורה בזמן ההקלדה. + state.screen = JSON.parse(await js('JSON.stringify([screenX, screenY])')); + state.inner = JSON.parse(await js('JSON.stringify([innerWidth, innerHeight])')); + region = { RX: state.screen[0], RY: state.screen[1] + caret.y - 8, RW: state.inner[0], RH: caret.h * 3 + 16 }; + console.log(`אזור המסך הנדגם: (${region.RX},${region.RY}) ${region.RW}×${region.RH}`); + } + const textBefore = await js('window.__otzariaEditor.container.textContent.length'); + const r = runPs({ Mode: 'type', Hwnd: hwnd, X: 0, Y: 0, Count: KEYS, GapMs: GAP_MS, ...region }); + await sleep(2500); + const O = JSON.parse(await js('JSON.stringify(window.__inapp)')); + const textAfter = await js('window.__otzariaEditor.container.textContent.length'); + result = { sent: r.keys, O, textBefore, textAfter, focus: `${r.focusBefore} → ${r.focusAfter}`, cpu: r.cpu }; + } +} finally { + ws.close(); + rmSync(tmp, { recursive: true, force: true }); +} +if (!result) process.exit(0); + +/* ------------------------------ החשבון ------------------------------ */ +const { sent, O } = result; +const stat = (xs) => { + if (!xs.length) return 'אין'; + const s = [...xs].sort((a, b) => a - b); + const q = (p) => s[Math.min(s.length - 1, Math.floor(p * s.length))]; + return `n=${s.length} חציון=${Math.round(q(0.5))} p90=${Math.round(q(0.9))} מקס=${Math.round(s[s.length - 1])}`; +}; +const failed = sent.filter((k) => k.sent !== 'ok'); +console.log(`\nנשלחו ${sent.length} הקשות${failed.length ? ` (${failed.length} נכשלו: ${failed[0].sent})` : ''}; keydown בדף: ${O.keys.length}; טקסט ב-DOM ${result.textBefore} → ${result.textAfter}; פוקוס המקלדת: ${result.focus}`); +if (result.cpu) { + // עומס זר בזמן הפרץ (בנייה, vitest, Chrome של שער QA מסשן אחר): על שתי ליבות הוא מכפיל את + // עלות ההקשה, והתוצאה נראית כמו „המארח איטי”. מדידה עם עומס זר ניכר אינה מדידה. + const c = result.cpu; + const top = (c.top || []).map((t) => `${t.name} ${t.pct}%`).join(', '); + const foreign = c.others > 50 * (c.logical / 4); + console.log(`CPU בזמן הפרץ (אחוז מליבה לוגית אחת, ${c.logical} ליבות לוגיות): אוצריא+WebView2+הכלי ${c.measured}%, תהליכים אחרים ${c.others}%${top ? ` (${top})` : ''}${foreign ? ' — ⚠ עומס זר במחשב: המדידה מזוהמת, להריץ שוב כשהמחשב שקט' : ''}`); +} +if (O.keys.length === 0) { + console.log('אף הקשה לא הגיעה לדף: פוקוס המקלדת של המערכת אינו על חלון ה-WebView (Chrome_WidgetWin_0). להריץ בלי --no-click, או ללחוץ במסמך ידנית.'); + process.exit(1); +} +const n = Math.min(sent.length, O.keys.length); +// שיוך תו↔הקשה: כשמספר עדכוני הטקסט שווה למספר ההקשות — לפי סדר (המנוע מעבד הקשה +// אחת בכל פעם ואינו מאחד); אחרת כל עדכון משויך להקשה האחרונה שקדמה לו. +const inOrder = O.text.length === O.keys.length; +const textOf = (i) => { + if (inOrder) return O.text[i] ?? null; + const k = O.keys[i], next = O.keys[i + 1]; + return O.text.find((x) => x.t > k.down && (!next || x.t <= next.down)) ?? null; +}; +const host = [], dom = [], paint = [], total = [], present = [], screen = []; +for (let i = 0; i < n; i++) { + const s = sent[i], k = O.keys[i], x = textOf(i); + host.push(k.down - s.t); + if (x) { + dom.push(x.t - k.down); + if (x.painted !== null) { + paint.push(x.painted - x.t); + total.push(x.painted - s.t); + if (s.screenMs >= 0) { + screen.push(s.screenMs); + present.push(s.t + s.screenMs - x.painted); + } + } + } +} +console.log(`עדכוני טקסט ב-DOM: ${O.text.length}${inOrder ? '' : ` (≠ ${O.keys.length} הקשות — השיוך לפי „ההקשה האחרונה שלפני”)`}`); +console.log('\n== לכל הקשה (ms) =='); +console.log('מערכת ההפעלה → keydown בדף: ' + stat(host)); +console.log('keydown → התו ב-DOM: ' + stat(dom) + ' (המנוע: עסקה → worker → ציור ל-DOM)'); +console.log('התו ב-DOM → ציור (rAF): ' + stat(paint)); +console.log('הקשה → ציור בדף, יחד: ' + stat(total)); +if (SCREEN) { + console.log('ציור בדף → פיקסלים על המסך: ' + stat(present) + ' (המארח: WebView2 → Capture → Flutter)'); + console.log('הקשה → פיקסלים על המסך, יחד: ' + stat(screen)); + const unseen = sent.filter((s) => s.screenMs < 0).length; + if (unseen) console.log(`הקשות בלי שינוי נראה באזור הנדגם: ${unseen} (הסמן יצא מהאזור, או שהתו התמזג עם הבא)`); +} +if (O.text.length && O.keys.length) { + // המדד שהמשתמש מרגיש בהקלדה מהירה: כמה אחרי ההקשה האחרונה הטקסט מתיישב. + const lastKey = O.keys[O.keys.length - 1].down, lastText = O.text[O.text.length - 1].t; + console.log(`ההקשה האחרונה → התו האחרון ב-DOM: ${Math.round(lastText - lastKey)}ms (מרווח ההקלדה ${GAP_MS}ms; ערך שגדל בהרבה מעל הקשה בודדת = המנוע לא עומד בקצב, ההקשות נערמות)`); +} +console.log(`תווים שלא נצפו כמצוירים בנפרד: ${O.text.filter((x) => x.painted === null).length}`); +const slow = []; +for (let i = 0; i < n; i++) { + const s = sent[i], k = O.keys[i], x = textOf(i); + // התו מ-SendInput (מקש unicode מגיע כ-`Unidentified` ב-e.key). + if (x && x.t - s.t > 150) slow.push(`#${i} ‚${s.ch || k.key}‘ ${Math.round(x.t - s.t)}`); +} +if (slow.length) console.log('הקשות איטיות (>150ms עד התו ב-DOM): ' + slow.join(', ')); +const t0 = sent[0]?.t ?? 0; +const long = O.long.filter((l) => l.start >= t0 - 200); +console.log(`\nlong tasks בזמן ההקלדה: ${long.length}${long.length ? ' — ' + long.map((l) => l.dur + 'ms').join(', ') : ''}`); +if (O.vis.length) console.log('שינויי נראות בזמן הריצה: ' + O.vis.map((v) => v.vis).join(', ')); diff --git a/scripts/typing-latency-probe.mjs b/scripts/typing-latency-probe.mjs new file mode 100644 index 00000000..9d96be37 --- /dev/null +++ b/scripts/typing-latency-probe.mjs @@ -0,0 +1,445 @@ +/** + * בדיקת אבחון (לא שער): זמן הקלדה מלחיצת המקש עד שהתו מצויר, long tasks על + * החוט הראשי, והודעות קונסולה בזמן ההקלדה — על ה-dist הארוז. + * + * זה הכלי שמצא את חישוב הסגנון של כל המסמך בכל תו (ראו README, „איטיות + * בהקלדה”); השער הקבוע שנגזר ממנו הוא scripts/qa/typing-style-recalc-qa.mjs. + * הכלי נשאר מפני שהוא עונה על שאלות שהשער אינו שואל: כמה זמן באמת עובר עד + * הציור, האם התוסף שקט בקונסולה, ומה קורה **בתוך אוצריא** ולא רק ב-Chrome. + * + * שני מצבים: + * - ברירת מחדל: פותח את ה-dist ב-Chrome headless (file://) עם דמה של המאחז. + * - --attach : מתחבר לדף התוסף שכבר רץ ב-WebView2 עם יציאת CDP. את + * אוצריא מריצים לפני כן עם + * WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=. + * **לבדוק את שורת „פוקוס” בפלט**: `vis: hidden` פירושו חלון ממוזער — rAF + * לא רץ וטיימרים מוגבלים, וזה נראה כמו הקלדה איטית ואינו כזה. + * המקשים כאן סינתטיים (CDP); למסלול האמיתי — הקשת מערכת ההפעלה ועד הפיקסלים + * על המסך — יש scripts/typing-latency-inapp.mjs. + * + * אפשרויות: + * --docx פותח מסמך דרך דמה של בורר הקבצים (ברירת המחדל: המסמך הריק) + * --tab

+ * + * הרצה: node scripts/typing-latency-probe.mjs [--docx c:/path/doc.docx] + */ +import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { openPage, sleep } from './cdp.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const opt = (name, dflt) => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : dflt; +}; +const DIST = opt('--dist', join(ROOT, 'dist')); +const PROFILE_OUT = opt('--profile', null); +const TRACE_OUT = opt('--trace', null); +/** trace קל: בלי invalidationTracking ובלי stack — אלה מכבידים על הדף עצמו ומסיטים את המדידה במסמכים גדולים. */ +const TRACE_LIGHT = args.includes('--trace-light'); +const DROP_CSS = opt('--drop-css', null); +const PORT = Number(opt('--port', 9361)); +const LABEL = opt('--label', 'lat'); +const GAP_MS = Number(opt('--gap', 80)); +const DOCX = opt('--docx', null); +const TAB = opt('--tab', null); +const ATTACH = opt('--attach', null); +const COUNTS = [Number(opt('--n1', 60)), Number(opt('--n2', 40)), Number(opt('--n3', 40))]; +/** כמה להמתין לפתיחת המסמך שנמסר ב---docx. מסמך של מאות עמודים צריך יותר מדקה. */ +const OPEN_WAIT_MS = Number(opt('--open-wait', 60_000)); +/** שהות אחרי שהמסמך נפתח ולפני ההקלדה. מסמך גדול „מסדר את התצוגה” כמה שניות אחרי שהטקסט כבר בעץ, והקלדה בזמן הזה מודדת את הפתיחה. */ +const SETTLE_MS = Number(opt('--settle', 3000)); +const PROFILE_INTERVAL_US = Number(opt('--profile-interval', 250)); + +const HOST_STUB = readFileSync(join(ROOT, 'scripts', 'qa', 'host-stub.js'), 'utf8'); +const QA_API = readFileSync(join(ROOT, 'scripts', 'qa', 'qa-api.js'), 'utf8'); + +/** המדידה בתוך הדף. מותקנת דרך `install()` אחרי שהמסמך פתוח. */ +const INSTRUMENT_JS = ` +(function () { + if (window.__lat) return; + var L = (window.__lat = { keys: [], long: [], phases: [], consoleCounts: {}, visChanges: [], installed: false }); + L.mark = function (name) { L.phases.push({ name: name, t: performance.now() }); try { performance.mark('lat:' + name); } catch (e) {} return name; }; + document.addEventListener('visibilitychange', function () { L.visChanges.push({ t: performance.now(), vis: document.visibilityState }); }); + ['log', 'info', 'warn', 'error', 'debug'].forEach(function (level) { + var orig = console[level]; + console[level] = function () { + try { + var key = level + ': ' + Array.prototype.slice.call(arguments, 0, 2).map(function (a) { return typeof a === 'string' ? a : (a && a.message) || typeof a; }).join(' ').slice(0, 70); + L.consoleCounts[key] = (L.consoleCounts[key] || 0) + 1; + } catch (e) {} + return orig.apply(console, arguments); + }; + }); + L.install = function () { + if (L.installed) return 'already'; + var session = window.__otzariaEditor; + if (!session) return 'no-editor'; + var container = session.container; + var pending = null; + var lenOf = function () { return container.textContent.length; }; + window.addEventListener('keydown', function (e) { + if (e.key.length !== 1) return; + pending = { key: e.key, down: performance.now(), mutated: null, painted: null, lenAtDown: lenOf(), vis: document.visibilityState }; + L.keys.push(pending); + }, true); + // „צויר” = המוטציה הראשונה שהגדילה את הטקסט, ואחריה פריים אחד. + new MutationObserver(function () { + if (!pending || pending.mutated !== null) return; + if (lenOf() <= pending.lenAtDown) return; + var p = pending; + p.mutated = performance.now(); + requestAnimationFrame(function () { p.painted = performance.now(); }); + }).observe(container, { childList: true, subtree: true, characterData: true }); + try { + new PerformanceObserver(function (list) { + list.getEntries().forEach(function (e) { L.long.push({ start: Math.round(e.startTime), dur: Math.round(e.duration) }); }); + }).observe({ type: 'longtask', buffered: true }); + } catch (err) { L.longError = String(err); } + L.installed = true; + return 'ok'; + }; +})(); +`; + +/** דמה של „פתח קובץ”: הבורר מחזיר blob: של הקובץ שנמסר. */ +function docxStub(path) { + const b64 = readFileSync(path).toString('base64'); + const name = path.split(/[\\/]/).pop(); + return ` +`; +} + +const TEXT = 'בראשית ברא אלהים את השמים ואת הארץ והארץ היתה תהו ובהו וחשך על פני תהום ורוח אלהים מרחפת על פני המים '; +const vkFor = (ch) => (ch === ' ' ? { code: 'Space', vk: 32 } : { code: 'KeyA', vk: 65 }); + +/** + * חיבור CDP לדף קיים. יודע לקבל אירועים — `Tracing.dataCollected` מגיע כאירוע, + * ו-cdp.mjs מטפל רק בתשובות לפי id. CDP מרשה כמה לקוחות לאותו יעד. + */ +async function attachTo(port) { + const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + const page = list.find((t) => t.type === 'page' && /otzaria-word-editor\/dist\/index\.html|__lat-/.test(t.url)); + if (!page) throw new Error('לא נמצא דף של התוסף. דפים: ' + list.map((t) => t.type + ' ' + t.url).join(' | ')); + const socket = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', () => reject(new Error('CDP attach נכשל')), { once: true }); + }); + let nextId = 0; + const pending = new Map(); + const events = []; + const eventWaiters = []; + socket.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (message.id === undefined) { + events.push(message); + for (const w of eventWaiters.splice(0)) w(message); + return; + } + const settle = pending.get(message.id); + if (!settle) return; + pending.delete(message.id); + settle(message); + }); + const cdp = { + events, + waitForEvent(method, timeoutMs = 60000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout waiting ' + method)), timeoutMs); + const check = (m) => { + if (m.method === method) { + clearTimeout(timer); + resolve(m); + } else eventWaiters.push(check); + }; + eventWaiters.push(check); + }); + }, + send(method, params) { + const id = ++nextId; + return new Promise((resolve) => { + pending.set(id, resolve); + socket.send(JSON.stringify({ id, method, params })); + }); + }, + async evaluate(expression) { + const response = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + const result = response.result?.result; + if (response.result?.exceptionDetails) throw new Error(`CDP: הביטוי זרק — ${result?.description ?? 'ללא פירוט'}`); + return result?.value; + }, + close: () => socket.close(), + }; + return { cdp, close: () => socket.close(), url: page.url }; +} + +async function click(cdp, x, y) { + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }); + await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }); + await sleep(60); + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 }); +} + +let probe = null; +let opened; +if (ATTACH) { + opened = await attachTo(Number(ATTACH)); + console.log('מחובר ל-' + opened.url); +} else { + const index = join(DIST, 'index.html'); + if (!existsSync(index)) { + console.error('אין index.html ב-' + DIST + ' — הריצו npm run build'); + process.exit(1); + } + const html = readFileSync(index, 'utf8'); + const cut = html.indexOf('') + ''.length; + probe = join(DIST, `__lat-${LABEL}.html`); + const inject = `\n\n\n\n${DOCX ? docxStub(DOCX) : ''}\n`; + writeFileSync(probe, html.slice(0, cut) + inject + html.slice(cut)); + opened = await openPage(`file:///${probe.split('\\').join('/')}`, { port: PORT, label: LABEL }); +} + +let result = null; +try { + const { cdp, close } = opened; + try { + for (let waited = 0; ; waited += 500) { + if (waited >= 90_000) throw new Error('התוסף לא הגיע למסמך פתוח בזמן'); + await sleep(500); + if (await cdp.evaluate('!!window.__otzariaEditor && !document.getElementById("otzaria-splash") && !!document.querySelector(".superdoc-line, .superdoc-fragment")')) break; + } + await sleep(2500); + if (ATTACH) { + await cdp.evaluate('delete window.__lat'); + await cdp.evaluate(INSTRUMENT_JS); + } + + if (DOCX) { + // „פתח קובץ” דרך הרצועה, כמו המשתמש: לשונית „קובץ” → הפקד → „עיון בקבצים…”. + const tabRect = JSON.parse(await cdp.evaluate(`JSON.stringify(window.__qa.tabRect('קובץ'))`)); + if (!tabRect) throw new Error('לשונית „קובץ” לא נמצאה'); + await click(cdp, tabRect.x, tabRect.y); + await sleep(400); + const ctrl = JSON.parse(await cdp.evaluate(`JSON.stringify(window.__qa.rect('פתח קובץ', {}))`)); + if (!ctrl) throw new Error('הפקד „פתח קובץ” לא נמצא'); + await click(cdp, ctrl.x, ctrl.y); + await sleep(800); + const browse = JSON.parse( + await cdp.evaluate( + `JSON.stringify((function(){var d=document.querySelector('[role="dialog"]');if(!d)return null;var b=Array.prototype.find.call(d.querySelectorAll('button'),function(x){return /עיון בקבצים/.test(x.textContent||'')});return b?window.__qa.rectOf(b):null;})())`, + ), + ); + if (browse) await click(cdp, browse.x, browse.y); + let openedLen = 0; + for (let waited = 0; waited < OPEN_WAIT_MS; waited += 500) { + await sleep(500); + openedLen = await cdp.evaluate('(window.__otzariaEditor && window.__otzariaEditor.container.textContent.length) || 0'); + if (waited % 10_000 === 0 && waited > 0) console.log(`ממתין לפתיחה… ${waited / 1000}s, טקסט ${openedLen}, עמודים ${await cdp.evaluate('document.querySelectorAll("[data-page-index]").length')}`); + if (openedLen > 2000) break; + } + if (openedLen <= 2000) console.log('אזהרה: המסמך לא נפתח בזמן ההמתנה — הקונסולה עד כאן: ' + (await cdp.evaluate('JSON.stringify(window.__lat.consoleCounts)'))); + await sleep(SETTLE_MS); + } + + if (TAB) { + const rect = JSON.parse(await cdp.evaluate(`JSON.stringify(window.__qa.tabRect(${JSON.stringify(TAB)}))`)); + if (!rect) throw new Error(`לשונית „${TAB}” לא נמצאה`); + await click(cdp, rect.x, rect.y); + await sleep(500); + } + + // שורת טקסט גלויה (השנייה, כשיש) — לחיצה על שטח ריק ממקדת בלי לפתור יעד. + const line = await cdp.evaluate( + '(function(){var ls=[...document.querySelectorAll(".superdoc-line")].filter(function(l){var r=l.getBoundingClientRect();return r.width>40&&r.height>8&&r.top>0&&r.top= 0; j--) { + var r = rules[j]; + if (r.selectorText && re.test(r.selectorText)) { out.push(r.selectorText.slice(0, 160)); sheet.deleteRule(j); } + } + } + return JSON.stringify(out); + })()`); + console.log('כללי CSS שהוסרו לצורך המדידה: ' + dropped); + await sleep(500); + } + + const installed = await cdp.evaluate('window.__lat.install()'); + if (installed !== 'ok') throw new Error('התקנת המדידה נכשלה: ' + installed); + // שגיאות שנצברו עד כאן — בפתיחה — נדפסות לפני האיפוס, כדי שפתיחה שנכשלה לא תיעלם. + const beforeTyping = JSON.parse(await cdp.evaluate('JSON.stringify(window.__lat.consoleCounts)')); + const loud = Object.entries(beforeTyping).filter(([k]) => /^(error|warn)/.test(k)); + if (loud.length) console.log('קונסולה לפני ההקלדה: ' + loud.map(([k, v]) => `${v}× ${k}`).join(' | ')); + await cdp.evaluate('window.__lat.consoleCounts = {}'); + + let tracer = null; + if (TRACE_OUT) { + tracer = await attachTo(ATTACH ? Number(ATTACH) : PORT); + await tracer.cdp.send('Tracing.start', { + categories: TRACE_LIGHT + ? 'devtools.timeline,disabled-by-default-devtools.timeline,blink.user_timing' + : 'devtools.timeline,disabled-by-default-devtools.timeline,disabled-by-default-devtools.timeline.invalidationTracking,blink.user_timing,disabled-by-default-devtools.timeline.stack', + transferMode: 'ReportEvents', + }); + } + if (PROFILE_OUT) { + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { interval: PROFILE_INTERVAL_US }); + await cdp.send('Profiler.start'); + } + + const cdpTimes = []; + const typeChars = async (count, phase) => { + await cdp.evaluate(`window.__lat.mark(${JSON.stringify(phase)})`); + for (let i = 0; i < count; i++) { + const ch = TEXT[i % TEXT.length]; + const { code, vk } = vkFor(ch); + const t0 = Date.now(); + await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, code, text: ch, unmodifiedText: ch, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk }); + await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk }); + cdpTimes.push({ phase, ms: Date.now() - t0 }); + await sleep(GAP_MS); + } + }; + + // שלושה שלבים: רצוף; אחרי הפסקה קצרה (autosave ב-2.5s); אחרי 12s (כתיבת טיוטה ב-10s). + await typeChars(COUNTS[0], 'A-רצוף'); + await cdp.evaluate('window.__lat.mark("B-הפסקה 4s")'); + await sleep(4000); + await typeChars(COUNTS[1], 'C-אחרי הפסקה'); + await cdp.evaluate('window.__lat.mark("D-הפסקה 12s")'); + await sleep(12_000); + await typeChars(COUNTS[2], 'E-אחרי טיוטה'); + await cdp.evaluate('window.__lat.mark("F-סוף")'); + await sleep(3000); + + if (PROFILE_OUT) { + const { result: prof } = await cdp.send('Profiler.stop'); + writeFileSync(PROFILE_OUT, JSON.stringify(prof.profile)); + } + if (tracer) { + await tracer.cdp.send('Tracing.end'); + await tracer.cdp.waitForEvent('Tracing.tracingComplete', 120000); + const all = []; + for (const ev of tracer.cdp.events) if (ev.method === 'Tracing.dataCollected') all.push(...ev.params.value); + writeFileSync(TRACE_OUT, JSON.stringify(all)); + console.log('trace: ' + all.length + ' אירועים → ' + TRACE_OUT); + tracer.close(); + } + + result = JSON.parse( + await cdp.evaluate( + 'JSON.stringify({ lat: window.__lat, calls: (window.__qaHost && window.__qaHost.calls) || [], pages: document.querySelectorAll("[data-page-index]").length, textLen: window.__otzariaEditor.container.textContent.length })', + ), + ); + result.cdpTimes = cdpTimes; + } finally { + close(); + } +} finally { + if (probe) rmSync(probe, { force: true }); +} + +/* ------------------------------ החשבון ------------------------------ */ +const { lat, calls } = result; +const t0 = lat.keys[0]?.down ?? 0; +const phaseOf = (t) => { + let name = '?'; + for (const p of lat.phases) if (p.t <= t) name = p.name; + return name; +}; +const stat = (xs) => { + if (!xs.length) return 'אין'; + const s = [...xs].sort((a, b) => a - b); + const q = (p) => s[Math.min(s.length - 1, Math.floor(p * s.length))]; + return `n=${s.length} חציון=${Math.round(q(0.5))} p90=${Math.round(q(0.9))} מקס=${Math.round(s[s.length - 1])}`; +}; + +console.log(`עמודים: ${result.pages}, אורך טקסט: ${result.textLen}`); +console.log('\n== זמן מלחיצה עד צביעה (ms), לפי שלב =='); +const byPhase = new Map(); +for (const k of lat.keys) { + const ph = phaseOf(k.down) + (k.vis ? ` (${k.vis})` : ''); + if (!byPhase.has(ph)) byPhase.set(ph, []); + if (k.painted !== null) byPhase.get(ph).push(k.painted - k.down); +} +for (const [ph, xs] of byPhase) console.log(`${ph}: ${stat(xs)}`); +console.log(`תווים שלא נצפו כמצוירים בנפרד (התמזגו עם הבא): ${lat.keys.filter((k) => k.painted === null).length} מתוך ${lat.keys.length}`); + +console.log('\n== CDP: זמן קריאת dispatchKeyEvent (ms) =='); +const cdpBy = new Map(); +for (const c of result.cdpTimes) { + if (!cdpBy.has(c.phase)) cdpBy.set(c.phase, []); + cdpBy.get(c.phase).push(c.ms); +} +for (const [ph, xs] of cdpBy) console.log(`${ph}: ${stat(xs)}`); + +console.log('\n== הקשות איטיות (>150ms עד צביעה) =='); +for (const k of lat.keys) { + if (k.painted === null || k.painted - k.down <= 150) continue; + console.log(`t=${Math.round(k.down - t0)}ms ‚${k.key}‘ צביעה+${Math.round(k.painted - k.down)} [${phaseOf(k.down)}]`); +} + +console.log(`\n== long tasks (>50ms): ${lat.long.filter((l) => l.start >= t0 - 500).length} ==`); +if (lat.longError) console.log('longtask לא נתמך: ' + lat.longError); +for (const l of lat.long) { + if (l.start < t0 - 500) continue; + console.log(`t=${Math.round(l.start - t0)}ms משך ${l.dur}ms [${phaseOf(l.start)}]`); +} + +console.log('\n== הודעות קונסולה בזמן ההקלדה =='); +for (const [k, v] of Object.entries(lat.consoleCounts).sort((a, b) => b[1] - a[1]).slice(0, 12)) console.log(`${v}× ${k}`); + +console.log('\n== קריאות למאחז מאז תחילת ההקלדה =='); +for (const c of calls) { + if (c.t < t0 - 500) continue; + console.log(`t=${Math.round(c.t - t0)}ms ${c.method} [${phaseOf(c.t)}]`); +} +console.log('\n== שינויי נראות בזמן הריצה =='); +for (const v of lat.visChanges) console.log(`t=${Math.round(v.t - t0)}ms ${v.vis}`); +console.log('\n== שלבים =='); +for (const p of lat.phases) console.log(`t=${Math.round(p.t - t0)}ms ${p.name}`); diff --git a/src/styles/engine-chrome.css b/src/styles/engine-chrome.css index 33bc06bc..9b3939ba 100644 --- a/src/styles/engine-chrome.css +++ b/src/styles/engine-chrome.css @@ -117,7 +117,23 @@ העיגון הוא ב-`data-superdoc-v2-edit-rejected` — תכונה סמנטית — ולא במחלקה. tests/contract/engine-edit-rejected-banner.test.ts מאמת שהיא עדיין קיימת באריזה: אם SuperDoc יסגור את הבאג או ישנה את המבנה, הבדיקה - תיפול, וזה הסימן למחוק את הכלל הזה במקום להשאיר הסתרה של DOM שאינו קיים. */ -:root:not([data-menu-locale='en']) .superdoc__mutation-status:has([data-superdoc-v2-edit-rejected]) { + תיפול, וזה הסימן למחוק את הכלל הזה במקום להשאיר הסתרה של DOM שאינו קיים. + **למה `> [data-…]` ולא `:has()`, וזה נמדד.** הגרסה הראשונה של הכלל הסתירה + את העוטף דרך `.superdoc__mutation-status:has([data-superdoc-v2-edit-rejected])`, + וזה היה מקור האיטיות בהקלדה: `:has()` שהעוגן שלו יושב בתוך `.superdoc` + גרם ל-Blink לסמן את `.superdoc` כמושפע מ-`:has()`, ומרגע זה כל הוספה או + הסרה של צומת במסמך — כלומר כל תו שנקלד — תזמנה חישוב סגנון מחדש של + **כל תת-העץ** (2,748 אלמנטים במסמך של חמישה עמודים, 60–115ms לחישוב). + ב-50 הקשות נמדדו 42 חישובים כאלה ו-53 long tasks; מחיקת הכלל הזה בלבד, + בזמן ריצה ולפני ההקלדה, הורידה אותם ל-0 ול-3, ומחיקת כלל אקראי אחר לא + שינתה דבר (trace של Chrome, `disabled-by-default-devtools.timeline.invalidationTracking`: + „Affected by :has()” על `DIV.superdoc`, „Invalidation set invalidates + subtree”). הסלקטור כאן מסתיר את ה-`

` שנושא את התכונה ולא את העוטף — + העוטף הוא `height: 0` בלי ריפוד ורקע (SuperDoc.vue, `.superdoc__mutation-status`), + ולכן אין הבדל נראה — ובלי `:has()` בכלל. שער ההיגיינה + (tests/unit/css-hygiene.test.ts) חוסם `:has()` חדש בגיליונות, ו- + scripts/qa/typing-style-recalc-qa.mjs סופר את החישובים המלאים בזמן הקלדה + בדפדפן אמיתי, כדי שהתופעה לא תחזור בשקט. */ +:root:not([data-menu-locale='en']) .superdoc__mutation-status > [data-superdoc-v2-edit-rejected] { display: none; } diff --git a/tests/contract/engine-edit-rejected-banner.test.ts b/tests/contract/engine-edit-rejected-banner.test.ts index 04a50e73..2ea47814 100644 --- a/tests/contract/engine-edit-rejected-banner.test.ts +++ b/tests/contract/engine-edit-rejected-banner.test.ts @@ -46,11 +46,30 @@ describe('חוזה באנר edit-rejected', () => { expect(shell).toContain('This edit couldn’t be completed.'); }); + /** שורת הסלקטור של הכלל — בלי הערות, שגם הן מזכירות את שני העוגנים. */ + const ruleLine = sheet + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .find((line) => line.includes(MUTATION_STATUS_CLASS) && line.includes(EDIT_REJECTED_HOOK)); + it('הכלל קיים בגיליון ומגודר בשער השפה', () => { - const rule = sheet - .split('\n') - .find((line) => line.includes(MUTATION_STATUS_CLASS) && line.includes(EDIT_REJECTED_HOOK)); - expect(rule, 'אין בגיליון כלל שמסתיר את הבאנר').toBeTruthy(); - expect(rule).toContain(`:root:not([${MENU_LOCALE_ATTRIBUTE}='en'])`); + expect(ruleLine, 'אין בגיליון כלל שמסתיר את הבאנר').toBeTruthy(); + expect(ruleLine).toContain(`:root:not([${MENU_LOCALE_ATTRIBUTE}='en'])`); + }); + + it('הכלל מסתיר את ה-

כילד ישיר של העוטף, ובלי :has()', () => { + // `:has()` שעוגנו בתוך `.superdoc` הוא מה שגרם לחישוב סגנון של כל המסמך + // על כל הקשה (ראו הערת הפתיחה, סעיף 4). הבדיקה נועלת את הצורה שנמדדה. + expect(ruleLine).not.toContain(':has('); + expect(ruleLine).toContain(`.${MUTATION_STATUS_CLASS} > [${EDIT_REJECTED_HOOK}]`); + // ובאריזה: ה-

אכן ילד ישיר של העוטף — אחרת `>` היה מסתיר כלום. ברינדור + // המקומפל של Vue העוטף וה-

נוצרים ברצף, בלי createElementVNode שלישי + // ביניהם. + const wrapper = shell.indexOf(MUTATION_STATUS_CLASS); + const hook = shell.indexOf(EDIT_REJECTED_HOOK, wrapper); + expect(wrapper, 'העוטף לא נמצא באריזה').toBeGreaterThan(-1); + expect(hook, 'התכונה אינה מופיעה אחרי העוטף').toBeGreaterThan(wrapper); + const between = shell.slice(wrapper, hook); + expect((between.match(/createElementVNode\(|createBaseVNode\(|createVNode\(/g) ?? []).length).toBeLessThanOrEqual(1); }); }); diff --git a/tests/unit/css-hygiene.test.ts b/tests/unit/css-hygiene.test.ts index 6cd4a65f..f493eae0 100644 --- a/tests/unit/css-hygiene.test.ts +++ b/tests/unit/css-hygiene.test.ts @@ -13,6 +13,8 @@ * 2. כל מחלקה שמופיעה בסלקטור בגלובלים (src/styles/*.css) קיימת באמת * בקומפוננטה או בקוד. * 3. כל var(--word-*) שנצרך אכן מוגדר — טוקן שלא הוגדר פשוט לא צובע כלום. + * 4. אין `:has()` בסלקטור שלא אושר במפורש. ראו „:has() בסלקטורים” למטה — + * זה אינו סגנון אלא ביצועים שנמדדו. */ import { describe, expect, it } from 'vitest'; import { readFileSync, readdirSync, statSync } from 'node:fs'; @@ -135,6 +137,95 @@ describe('סלקטורים בגלובלים', () => { }); }); +/* ------------------------------------------------------------------ */ +/* :has() בסלקטורים */ +/* ------------------------------------------------------------------ */ + +/** + * `:has()` שהעוגן שלו יכול לשבת בתוך עץ המסמך של המנוע (`.superdoc`) הוא + * מוקש ביצועים שנמדד ולא הונח: כלל אחד כזה ב-engine-chrome.css — + * `.superdoc__mutation-status:has([data-superdoc-v2-edit-rejected])` — גרם + * ל-Blink לסמן את `.superdoc` כמושפע מ-`:has()`, ומרגע זה כל תו שנקלד תזמן + * חישוב סגנון מחדש של כל עץ המסמך: 42 חישובים של 2,748 אלמנטים (60–115ms + * כל אחד) ב-50 הקשות, ו-53 long tasks. מחיקת הכלל לבדה הורידה את שניהם + * ל-0 ול-3; מחיקת כלל אחר לא שינתה דבר. הסיפור המלא בהערה ליד הכלל + * ב-engine-chrome.css, והמדידה החוזרת ב-scripts/qa/typing-style-recalc-qa.mjs. + * + * הכלל כאן גס בכוונה: **כל** `:has()` בגיליונות ובקומפוננטות חייב להופיע + * ברשימה למטה עם הנימוק למה העוגן שלו אינו יכול להיות אב של צומת במסמך. + * מי שמוסיף `:has()` חדש נדרש להחליט, לא לגלוש. + */ +describe(':has() בסלקטורים', () => { + /** + * `.word-ribbon-group:has(+ .word-ribbon-group--end)` — העוגן הוא קבוצה + * ברצועה, מחוץ ל-`.editor-stack`, ולכן שינוי DOM במסמך אינו עובר דרכו. + * נמדד באותו ניסוי: מחיקת הכלל הזה בזמן ריצה לא שינתה את מספר החישובים + * המלאים (44 מול 42 בבסיס), בעוד מחיקת כלל הבאנר הורידה אותם ל-0. + */ + const ALLOWED = new Set(['.word-ribbon-group:has(+ .word-ribbon-group--end)']); + + /** פיצול רשימת סלקטורים בפסיקים שמחוץ לסוגריים — `:has(a, b)` נשאר שלם. */ + function splitSelectorList(selector: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const ch of selector) { + if (ch === '(') depth += 1; + if (ch === ')') depth -= 1; + if (ch === ',' && depth === 0) { + parts.push(current); + current = ''; + continue; + } + current += ch; + } + parts.push(current); + return parts.map((part) => part.replace(/\s+/g, ' ').trim()).filter(Boolean); + } + + /** כל הסלקטורים עם `:has(` בגיליון או בבלוקי `