ערימה, תור וטבלת גיבוב נכנסו לבר. זה אולי נשמע כמו התחלה של בדיחה אבל אלה מבני הנתונים שהשתמשתי בהם כדי לבנות מנהל קיצורי מקלדת והייתי רוצה לשתף אותכם בתהליך. הפוסט יכלול שימוש מוגזם ובלתי סביר במבני נתונים רק לצורך ההדגמה של איך הם יכולים לעזור לפתור בעיה אמיתית. יהיה כאן פסאודו קוד להבנת הקונספטים ולבסוף גם אציג דוגמה עובדת הכתובה ב-React אך חשוב לי להבהיר שעיקר הפוסט הוא לא איך לכתוב קוד אלה איך לפתור בעיה.
אז קיבלתי משימה ליישם באפליקציה עליה אני עובד מנהל קיצורי מקלדת. במימוש הנוכחי פשוט השתמשנו בספריה Mousetrap אך ככל שהמורכבות של האפליקציה גדלה ככה צצו להם בעיות אותם הספריה לא פתרה. הספריה דואגת לרשום את המקש ואת הקולבק אך במקרה ודרסנו מקש עם קומפוננטה חדשה, כאשר אותה קומפוננטה יורדת היא בעצם לא יודעת לרשום את הקולבק הקודם לאותו המקש. כמו כן הספריה לא יודעת להתמודד עם הדיאלוגים שקופצים, מקשים שנרשמו והיו שייכים לאזורים ברקע היו ניתנים לגישה בזמן שהדיאלוג הופיע, מה שגרם למשתמש לבצע פעולות באפליקציה למרות שכל הפוקוס של המשתמש אמור להיות על הדיאלוג.
יצאתי לחשוב איך אפשר לנהל את כל העניין ופישטתי כל אחת מהבעיות למבנה נתונים. אזורים באפליקציה עם תיעדוף שונה? ערימה(Heap). איזה מקשים רשומים ומה הקולבק של כל מקש? טבלת גיבוב(Map/Hash Table) . מקשים מתנגשים? תור(Queue). ועם שלושת מבני הנתונים הנ"ל אפשר לצאת לדרך להתחיל לכתוב קצת קוד.
המטרה הייתה ליצור API כמה שיותר פשוט, המקבל את המקש והקולבק(Callback) אותו רוצים להריץ בעת הלחיצה של אותו מקש. את המנגנון האחראי לרשום את ה-EventListener עבור כל מקש כבר יישמו רבים לפני ולכן החלטתי להמשיך להשתמש בספריה Mousetrap לצורך הזה.
איך להחליט איזה מקש מתי?

בעיה ראשונה אותה היה צריך לפתור היא כיצד מתמודדים עם אזורים באפליקציה אשר הקיצורים שלהם מתנגשים מבחינת מקשים עם איזורים אחרים. לשם כך נגדיר עבור כל איזור באפליקציה תחום(Scope) שונה אשר יקבע את הקדימות(Priority) שלו.
מכיוון שאני תמיד צריך את הערך עם הקדימות הגבוה ביותר החלטתי ללכת על ערימת מקסימום, כך בעצם תיהיה לי גישה מהירה ל-scope הגבוה ביותר שיש בו את אותו מקש שנלחץ. כמו כן גישה מהירה לערך המקסימלי עוזרת להתמודד במקרה וקופץ דיאלוג, כך בעצם בלחיצה על מקש לפני שאני מריץ את הקולבק שלו אני בודק אם ה-scope הגבוה ביותר שנרשם הוא דיאלוג ואז יודע בהתאם אם לחסום או לא את הקולבק.
חלוקת ה-Scope-ים תלויה בתכנון האפליקציה, במקרים רבים מספיק להגדיר אחת עבור דיאלוגים ואחת לכל השאר. אך בשביל שזה יהיה מעניין יותר נסבך טיפה את הסיפור.
כל מקש והקולבק שלו
הדרך המהירה ביותר לגשת למידע היא לגשת אליו ישירות, במקרה הנ"ל הסיפור פשוט, איזה קולבק מקושר למקש F? פשוט מאוד אלך למקום ה-F ואבדוק איזה קלבוק עלי להריץ.

התנגשות מקשים
נניח והגדרתי מספר מסוים של אזורים בעלי אותה קדימות ואותם איזורים רושמים קיצור עבור אותו מקש, מכיוון שהקדימות שלהם זהה לא משנה לי איזה קולבק ירוץ קודם. לדוגמא, קופץ דיאלוג ואז עליו עוד דיאלוג, איך יודעים איזה דיאלוג לסגור קודם?
במקרים שהקדימות היא אותה קדימות הנחה מספקת ברוב המקרים היא להניח שמי שנרשם אחרון הוא בעל הקדימות הגבוהה יותר, ולכן כאשר נלחץ על המקש נריץ את הקולבק האחרון שנרשם.
שיטה נוספת לפתור בעיה זו היא באמצעות להריץ את הקולבק עליו הfocus של המשתמש נמצא. החשיבה מאחורי זה היא שאם המשתמש נמצא שם בין אם זה עם העכבר או עם המקלדת, סביר להניח שהפעולות שירצה לבצע קשורות לאותו אזור.
לתפור 3 מבני נתונים עם React Hooks
ועכשיו לחלק האומונתי. על מנת ליישם את כל הנאמר הנ"ל החלטתי לכתוב Custom Hook שמנגיש לי פונקציה הרושמת את המקש והקולבק. אותה פונקציה תדאג לעטוף את הקולבק אותו אני מעביר ב-High Order Function אשר לפני הרצת הקולבק יבדוק האם הוא מקיים את התנאים כדי לרוץ, זאת אומרת האם קיים דיאלוג ואם כן האם המקש נרשם מ-scope של דיאלוג או גבוה יותר. כמו כן ה-Hook ידע ב-unmount למחוק את הקולבק, הכפתור ולנקות את ה-EventListener.
const registerHotkey = useCallback(
(key, callbackFn) => {
function callback(event) {
const highestScope = handlers.peek().scope;
if (
(highestScope >= BLOCKING_SCOPE && scope >= BLOCKING_SCOPE) ||
highestScope < BLOCKING_SCOPE
) {
callbackFn(event);
}
}
keysRef.current.add(key);
const scopeHandlers =
getScopeHandlers(handlers, scope)?.handlers ?? new Map(); // if scope doesn't exists, create a new one
const keyHandlers = scopeHandlers.get(key) ?? []; // if no key, create a new one
keyHandlers.push({ callback, keysRef: keysRef.current });
scopeHandlers.set(key, keyHandlers);
handlers.push({ scope, handlers: scopeHandlers });
Mousetrap.bind(key, getCallback(handlers, key));
},
[scope]
);
נגדיר את אזורים שלנו כקבועים, אין משמעות למספרים אך העיקר שיהיה ביניהם מספיק מרווח למקרה ונרצה להוסיף אזורים חדשים בעתיד:
export const SCOPE = {
DEFAULT: 1,
FOOTER: 25,
HEADER: 50,
SIDEPANEL: 75,
MAIN: 100,
DIALOG: 150,
OVERLAY: 200,
MODAL: 225,
POPOVER: 250,
};
ולבסוף נקבל Hook המנגיש לי את הAPI שלו באופן הבא:
const { registerHotkey } = useHotkey({ scope: SCOPE.HEADER });
וכך יראה השימוש בו:
useEffect(() => {
registerHotkey("t", () => {
console.log(“you pressed ‘t’”);
});
}, [registerHotkey]);
בגלל שבחרתי ללכת בגישה פונקציונלית בכל רינדור מחדש יהיה לנו פונקציה חדשה של registerHotkey ולכן כדי לא להרגיז את הESLINT חייב להכניס כתלות ברשימת התלויות של useEffect ולכן ב-Custom Hook שלנו נעטוף אותה ב-useCallback כך שלא נייצר פונקציה חדשה בכל רינדור, וכמו כן שלא נירשום מחדש כל פעם את אותו הקיצור.
עבור כל מופע של קומפוננטה אני משתמש ב-useRef כדי לשמור את המקשים אותם אותה הקומפוננטה רושמת במהלך חייה, המופע הזה שנשאר אחיד במהלך כל חיי הקומפוננטה עוזר לי לעקוב אחרי המקשים אותם הקומפוננטה רשמה וגם לזהות איזה קולבק רשמה כל קומפוננטה. כך ב-Unmount של הקומפוננטה אני יודע איזה מקשים בדיוק למחוק ואיזה קולבקים שייכים לאותה הקומפוננטה.
המימוש המלא:
סיכום
עד כאן מנהל קיצורי המקלדת שלנו. אומנם מימוש לפרודקשן יכול להשתנות כדי להתאים לאפליקציה ולרמת היעילות הנדרשת אך העיקרון של אותם מבני הנתונים יכול לעזור כדי לפתור את בעיה בצורה הקריאה, הקלה ביותר לתחזוקה והרחבה בעתיד.
מציע לכם לנסות ליישם את זה בעצמכם, לנסות לשנות ולהשתמש במבני נתונים אחרים(לדוגמא במקום ערימה לניהול התיעדוף, להשתמש במפה).
ואחרי זה אומרים שמה שמלמדים בקורס מבני נתונים לא רלוונטי לחיים אה?