מבוא
בשנת 2010, F-i.com וצוות Google Chrome שיתפו פעולה בפיתוח אפליקציית אינטרנט חינוכית מבוססת HTML5 בשם 20 Things I Learned about Browsers and the Web (20 דברים שלמדתי על דפדפנים ועל האינטרנט) (www.20thingsilearned.com). אחד הרעיונות המרכזיים מאחורי הפרויקט הזה היה שהדרך הכי טובה להציג אותו היא בהקשר של ספר. התוכן של הספר עוסק בטכנולוגיות של האינטרנט הפתוח, ולכן היה חשוב לנו להישאר נאמנים לכך ולהפוך את המאגר עצמו לדוגמה למה שאפשר להשיג היום באמצעות הטכנולוגיות האלה.
החלטנו שהדרך הכי טובה ליצור תחושה של ספר מהעולם האמיתי היא לדמות את החלקים הטובים של חוויית הקריאה האנלוגית, תוך ניצול היתרונות של התחום הדיגיטלי בתחומים כמו ניווט. השקענו מאמצים רבים בעיצוב הגרפי והאינטראקטיבי של רצף הקריאה – במיוחד באופן שבו הדפים בספרים מתהפכים מדף אחד לדף אחר.
תחילת העבודה
במדריך הזה נסביר איך ליצור אפקט של דפדוף בדף באמצעות אלמנט canvas וקוד JavaScript. חלק מהקוד הבסיסי, כמו הצהרות על משתנים והרשמה ל-event listener, לא נכלל בקטעי הקוד במאמר הזה, לכן חשוב לעיין בדוגמה הפועלת.
לפני שמתחילים, כדאי לצפות בהדגמה כדי להבין מה אנחנו רוצים לבנות.
Markup
חשוב לזכור שתמיד מה שמציירים על בד הציור לא יכול להיכנס לאינדקס של מנועי חיפוש, להיבחר על ידי מבקר או להימצא על ידי חיפושים בדפדפן. לכן, התוכן שאיתו אנחנו עובדים מוצב ישירות ב-DOM ואז עובר מניפולציה על ידי JavaScript אם הוא זמין. הסימון שנדרש לכך הוא מינימלי:
<div id='book'>
<canvas id='pageflip-canvas'></canvas>
<div id='pages'>
<section>
<div> <!-- Any type of contents here --> </div>
</section>
<!-- More <section>s here -->
</div>
</div>
יש לנו רכיב מאגר ראשי אחד לספר, שמכיל בתורו את הדפים השונים של הספר ואת רכיב canvas שעליו נצייר את הדפים המתהפכים. בתוך רכיב section יש רכיב wrapper div לתוכן – אנחנו צריכים אותו כדי לשנות את הרוחב של הדף בלי להשפיע על הפריסה של התוכן שלו. ל-div יש רוחב קבוע, והמאפיין section מוגדר להסתיר את הגלישה שלו. כתוצאה מכך, הרוחב של section פועל כמסכה אופקית עבור div.
לוגיקה
הקוד שנדרש כדי להפעיל את האפקט של דפדוף הדף לא מאוד מורכב, אבל הוא די ארוך כי הוא כולל הרבה גרפיקה שנוצרת באופן פרוצדורלי. נתחיל בתיאור של ערכי הקבועים שבהם נשתמש לאורך הקוד.
var BOOK_WIDTH = 830;
var BOOK_HEIGHT = 260;
var PAGE_WIDTH = 400;
var PAGE_HEIGHT = 250;
var PAGE_Y = ( BOOK_HEIGHT - PAGE_HEIGHT ) / 2;
var CANVAS_PADDING = 60;
המסגרת CANVAS_PADDING מתווספת מסביב לקנבס כדי שהדף יורחב מחוץ לספר כשדפדפים. שימו לב שחלק מהקבועים שמוגדרים כאן מוגדרים גם ב-CSS, ולכן אם אתם רוצים לשנות את הגודל של הספר, תצטרכו לעדכן גם את הערכים שם.
בשלב הבא צריך להגדיר אובייקט של דפדוף לכל דף. האובייקטים האלה יעודכנו כל הזמן בזמן האינטראקציה עם הספר, כדי לשקף את הסטטוס הנוכחי של הדפדוף.
// Create a reference to the book container element
var book = document.getElementById( 'book' );
// Grab a list of all section elements (pages) within the book
var pages = book.getElementsByTagName( 'section' );
for( var i = 0, len = pages.length; i < len; i++ ) {
pages[i].style.zIndex = len - i;
flips.push( {
progress: 1,
target: 1,
page: pages[i],
dragging: false
});
}
קודם כל צריך לוודא שהדפים מונחים בשכבות בצורה נכונה. לשם כך, מארגנים את ערכי המאפיין z-index של רכיבי הקטע כך שהדף הראשון יהיה למעלה והדף האחרון יהיה למטה. המאפיינים הכי חשובים של אובייקטים להחלפה הם הערכים progress ו-target.
הערכים האלה משמשים לקביעת מידת הקיפול של הדף כרגע. הערך -1 מציין קיפול עד הקצה השמאלי, הערך 0 מציין קיפול עד אמצע הספר והערך +1 מציין קיפול עד הקצה הימני של הספר.
אחרי שהגדרנו אובייקט flip לכל דף, צריך להתחיל לתעד את קלט המשתמשים ולהשתמש בו כדי לעדכן את מצב ה-flip.
function mouseMoveHandler( event ) {
// Offset mouse position so that the top of the book spine is 0,0
mouse.x = event.clientX - book.offsetLeft - ( BOOK_WIDTH / 2 );
mouse.y = event.clientY - book.offsetTop;
}
function mouseDownHandler( event ) {
// Make sure the mouse pointer is inside of the book
if (Math.abs(mouse.x) < PAGE_WIDTH) {
if (mouse.x < 0 && page - 1 >= 0) {
// We are on the left side, drag the previous page
flips[page - 1].dragging = true;
}
else if (mouse.x > 0 && page + 1 < flips.length) {
// We are on the right side, drag the current page
flips[page].dragging = true;
}
}
// Prevents the text selection
event.preventDefault();
}
function mouseUpHandler( event ) {
for( var i = 0; i < flips.length; i++ ) {
// If this flip was being dragged, animate to its destination
if( flips[i].dragging ) {
// Figure out which page we should navigate to
if( mouse.x < 0 ) {
flips[i].target = -1;
page = Math.min( page + 1, flips.length );
}
else {
flips[i].target = 1;
page = Math.max( page - 1, 0 );
}
}
flips[i].dragging = false;
}
}
הפונקציה mouseMoveHandler מעדכנת את האובייקט mouse כך שתמיד נתקדם למיקום האחרון של הסמן.
בקטע mouseDownHandler אנחנו מתחילים בבדיקה אם לחצו על העכבר בדף הימני או השמאלי, כדי לדעת לאיזה כיוון רוצים להתחיל להפוך את הדפים. אנחנו גם מוודאים שיש עוד דף בכיוון הזה, כי יכול להיות שאנחנו בדף הראשון או האחרון. אם אפשרות תקפה להעברה זמינה אחרי הבדיקות האלה,
אנחנו מגדירים את הדגל dragging של אובייקט ההעברה המתאים לערך true.
כשהמערכת מגיעה ל-mouseUpHandler, היא עוברת על כל flips ובודקת אם אחד מהם סומן כ-dragging וצריך לשחרר אותו עכשיו. כשמשחררים את ההיפוך, אנחנו מגדירים את ערך היעד שלו כך שיתאים לצד שאליו הוא צריך להתהפך, בהתאם למיקום הנוכחי של העכבר.
מספר הדף מתעדכן גם הוא כדי לשקף את הניווט הזה.
רינדור
עכשיו, אחרי שרוב הלוגיקה שלנו מוכנה, נסביר איך להציג את הנייר המקופל ברכיב Canvas. רוב הפעולות האלה מתבצעות בתוך הפונקציה render(), שמופעלת 60 פעמים בשנייה כדי לעדכן ולצייר את המצב הנוכחי של כל ההיפוכים הפעילים.
function render() {
// Reset all pixels in the canvas
context.clearRect( 0, 0, canvas.width, canvas.height );
for( var i = 0, len = flips.length; i < len; i++ ) {
var flip = flips[i];
if( flip.dragging ) {
flip.target = Math.max( Math.min( mouse.x / PAGE_WIDTH, 1 ), -1 );
}
// Ease progress towards the target value
flip.progress += ( flip.target - flip.progress ) * 0.2;
// If the flip is being dragged or is somewhere in the middle
// of the book, render it
if( flip.dragging || Math.abs( flip.progress ) < 0.997 ) {
drawFlip( flip );
}
}
}
לפני שמתחילים לעבד את flips, מאפסים את אזור הציור באמצעות השיטה clearRect(x,y,w,h). ניקוי כל האזור המשותף כרוך בעלויות ביצועים גבוהות, והרבה יותר יעיל לנקות רק את האזורים שבהם מציירים. כדי שהמדריך הזה יישאר ממוקד, נסתפק בהסבר על ניקוי כל האזור המשותף.
אם גוררים את ההיפוך, אנחנו מעדכנים את הערך target שלו כך שיתאים למיקום העכבר, אבל בסקאלה של -1 עד 1 ולא בפיקסלים בפועל.
בנוסף, אנחנו מגדילים את הערך של progress בחלק קטן מהמרחק אל target, וכך מתקבלת התקדמות חלקה ומאוּנמצת של ההיפוך, כי הערך מתעדכן בכל פריים.
מכיוון שאנחנו עוברים על כל ה-flips בכל פריים, אנחנו צריכים לוודא שאנחנו מציירים מחדש רק את אלה שפעילים. אם הדף לא נמצא קרוב מאוד לקצה הספר (בטווח של 0.3% מ-BOOK_WIDTH), או אם הוא מסומן כ-dragging, הוא נחשב פעיל.
עכשיו, אחרי שכל הלוגיקה מוכנה, צריך לצייר את הייצוג הגרפי של המטבע בהתאם למצב הנוכחי שלו. הגיע הזמן לבחון את החלק הראשון של הפונקציה drawFlip(flip).
// Determines the strength of the fold/bend on a 0-1 range
var strength = 1 - Math.abs( flip.progress );
// Width of the folded paper
var foldWidth = ( PAGE_WIDTH * 0.5 ) * ( 1 - flip.progress );
// X position of the folded paper
var foldX = PAGE_WIDTH * flip.progress + foldWidth;
// How far outside of the book the paper is bent due to perspective
var verticalOutdent = 20 * strength;
// The maximum widths of the three shadows used
var paperShadowWidth = (PAGE_WIDTH*0.5) * Math.max(Math.min(1 - flip.progress, 0.5), 0);
var rightShadowWidth = (PAGE_WIDTH*0.5) * Math.max(Math.min(strength, 0.5), 0);
var leftShadowWidth = (PAGE_WIDTH*0.5) * Math.max(Math.min(strength, 0.5), 0);
// Mask the page by setting its width to match the foldX
flip.page.style.width = Math.max(foldX, 0) + 'px';
החלק הזה של הקוד מתחיל בחישוב של מספר משתנים חזותיים שדרושים כדי לצייר את הקיפול בצורה ריאליסטית. progressהערך של ההיפוך שאנחנו מציירים משחק כאן תפקיד גדול, כי זה המקום שבו אנחנו רוצים שהקיפול של הדף יופיע. כדי להוסיף עומק לאפקט הדפדוף, אנחנו גורמים לדף להתרחב מעבר לקצוות העליונים והתחתונים של הספר. האפקט הזה מגיע לשיא כשהדפדוף קרוב לשדרה של הספר.
עכשיו, אחרי שכל הערכים מוכנים, נשאר רק לצייר את הנייר.
context.save();
context.translate( CANVAS_PADDING + ( BOOK_WIDTH / 2 ), PAGE_Y + CANVAS_PADDING );
// Draw a sharp shadow on the left side of the page
context.strokeStyle = `rgba(0,0,0,`+(0.05 * strength)+`)`;
context.lineWidth = 30 * strength;
context.beginPath();
context.moveTo(foldX - foldWidth, -verticalOutdent * 0.5);
context.lineTo(foldX - foldWidth, PAGE_HEIGHT + (verticalOutdent * 0.5));
context.stroke();
// Right side drop shadow
var rightShadowGradient = context.createLinearGradient(foldX, 0,
foldX + rightShadowWidth, 0);
rightShadowGradient.addColorStop(0, `rgba(0,0,0,`+(strength*0.2)+`)`);
rightShadowGradient.addColorStop(0.8, `rgba(0,0,0,0.0)`);
context.fillStyle = rightShadowGradient;
context.beginPath();
context.moveTo(foldX, 0);
context.lineTo(foldX + rightShadowWidth, 0);
context.lineTo(foldX + rightShadowWidth, PAGE_HEIGHT);
context.lineTo(foldX, PAGE_HEIGHT);
context.fill();
// Left side drop shadow
var leftShadowGradient = context.createLinearGradient(
foldX - foldWidth - leftShadowWidth, 0, foldX - foldWidth, 0);
leftShadowGradient.addColorStop(0, `rgba(0,0,0,0.0)`);
leftShadowGradient.addColorStop(1, `rgba(0,0,0,`+(strength*0.15)+`)`);
context.fillStyle = leftShadowGradient;
context.beginPath();
context.moveTo(foldX - foldWidth - leftShadowWidth, 0);
context.lineTo(foldX - foldWidth, 0);
context.lineTo(foldX - foldWidth, PAGE_HEIGHT);
context.lineTo(foldX - foldWidth - leftShadowWidth, PAGE_HEIGHT);
context.fill();
// Gradient applied to the folded paper (highlights & shadows)
var foldGradient = context.createLinearGradient(
foldX - paperShadowWidth, 0, foldX, 0);
foldGradient.addColorStop(0.35, `#fafafa`);
foldGradient.addColorStop(0.73, `#eeeeee`);
foldGradient.addColorStop(0.9, `#fafafa`);
foldGradient.addColorStop(1.0, `#e2e2e2`);
context.fillStyle = foldGradient;
context.strokeStyle = `rgba(0,0,0,0.06)`;
context.lineWidth = 0.5;
// Draw the folded piece of paper
context.beginPath();
context.moveTo(foldX, 0);
context.lineTo(foldX, PAGE_HEIGHT);
context.quadraticCurveTo(foldX, PAGE_HEIGHT + (verticalOutdent * 2),
foldX - foldWidth, PAGE_HEIGHT + verticalOutdent);
context.lineTo(foldX - foldWidth, -verticalOutdent);
context.quadraticCurveTo(foldX, -verticalOutdent * 2, foldX, 0);
context.fill();
context.stroke();
context.restore();
השיטה translate(x,y) של Canvas API משמשת להזזת מערכת הקואורדינטות כדי שנוכל לצייר את דפדוף הדף כשהחלק העליון של השדרה משמש כמיקום 0,0. שימו לב: אנחנו צריכים גם save() את מטריצת הטרנספורמציה הנוכחית של אזור הציור וrestore() אותה כשהציור מסתיים.
foldGradient הוא מה שנשתמש בו כדי למלא את הצורה של הנייר המקופל, כדי ליצור הדגשות וצללים שנראים מציאותיים. אנחנו גם מוסיפים קו דק מאוד מסביב לציור על הנייר, כדי שהנייר לא ייעלם כשמניחים אותו על רקעים בהירים.
כל מה שנותר עכשיו הוא לצייר את הצורה של הנייר המקופל באמצעות המאפיינים שהגדרנו למעלה. הצדדים הימני והשמאלי של הנייר מצוירים כקווים ישרים, והצדדים העליון והתחתון מעוגלים כדי ליצור את התחושה של נייר מקופל. החוזק של הקיפול הזה נקבע לפי הערך verticalOutdent.
זהו! עכשיו יש לכם ניווט בין דפים שפועל באופן מלא.
הדגמה של דפדוף בדף
האפקט של דפדוף בדף נועד להעביר את התחושה האינטראקטיבית הנכונה, ולכן צפייה בתמונות שלו לא ממחישה אותו בצורה מדויקת.
השלבים הבאים
זו רק דוגמה אחת למה שאפשר לעשות באמצעות תכונות של HTML5, כמו רכיב ה-canvas. מומלץ לעיין בחוויית הקריאה המעודנת יותר שמוצגת בספר שממנו נלקח הקטע הזה, בכתובת: www.20thingsilearned.com. שם אפשר לראות איך אפשר להשתמש בהעברת הדפים באפליקציה אמיתית, וכמה היא יעילה בשילוב עם תכונות אחרות של HTML5.
קובצי עזר
- מפרט ל-API של Canvas