מבוא
בשנת 2010, אתר F-i.com וצוות Google Chrome שיתפו פעולה בפיתוח אפליקציית אינטרנט חינוכית מבוססת HTML5 בשם 20 דברים שלמדתי על דפדפנים ועל האינטרנט (www.20thingsilearned.com). אחד הרעיונות המרכזיים שעמדו בבסיס הפרויקט הזה היה שהדרך הטובה ביותר להציג אותו היא בהקשר של ספר. התוכן של הספר עוסק בעיקר בטכנולוגיות של האינטרנט הפתוח, ולכן חשוב לנו להישאר נאמנים לכך ולהפוך את הקונטיינר עצמו לדוגמה למה שהטכנולוגיות האלה מאפשרות לנו להשיג היום.
החלטנו שהדרך הטובה ביותר ליצור תחושה של ספר בעולם האמיתי היא לדמות את החלקים הטובים בחוויית הקריאה האנלוגית, תוך ניצול היתרונות של המרחב הדיגיטלי בתחומים כמו ניווט. השקענו הרבה מאמץ בטיפול הגרפי והאינטראקטיבי בתהליך הקריאה – במיוחד בדרך שבה הדפים של הספרים עוברים מדף אחד לדף אחר.
תחילת העבודה
במדריך הזה נסביר איך יוצרים אפקט של הפיכת דפים באמצעות אלמנט הקנבס והרבה JavaScript. חלק מהקוד הבסיסי, כמו הצהרות על משתנים והרשמה למאזין לאירועים, לא נכלל בקטעי הקוד במאמר הזה, לכן חשוב לעיין בדוגמה העובדת.
לפני שנתחיל, כדאי לעיין בדמו כדי להבין מה אנחנו שואפים ליצור.
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
יש עטיפה div
של התוכן – אנחנו זקוקים לזה כדי שנוכל לשנות את הרוחב של הדף בלי להשפיע על הפריסה של התוכן שלו. ל-div
יש רוחב קבוע וה-section
מוגדר להסתיר את ה-overflow שלו. כתוצאה מכך, רוחב ה-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, כך שאם רוצים לשנות את הגודל של הספר, צריך לעדכן את הערכים גם שם.
בשלב הבא צריך להגדיר אובייקט Flip לכל דף. האובייקטים האלה יעודכנו באופן קבוע במהלך האינטראקציה שלנו עם הספר, כדי לשקף את המצב הנוכחי של ה-Flip.
// 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 של רכיבי הקטע כך שהדף הראשון יהיה בחלק העליון והדף האחרון יהיה בחלק התחתון. המאפיינים החשובים ביותר של אובייקטי ה-flip הם הערכים 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
ועכשיו אפשר לבטל את החסימה שלהם. כשמשחררים את היפוך, אנחנו מגדירים את ערך היעד שלו כך שיתאים לצד שאליו הוא אמור להתהפך, בהתאם למיקום הנוכחי של העכבר.
גם מספר הדף מתעדכן בהתאם לניווט.
רינדור
עכשיו, אחרי שרוב הלוגיקה שלנו מוכנה, נראה איך להציג את הנייר המתקפל ברכיב הקנבס. רוב הפעולות האלה מתבצעות בתוך הפונקציה 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 );
}
}
}
לפני שמתחילים לבצע עיבוד (render) של 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, כמו רכיב הקנבס. מומלץ לעיין בחוויית השימוש המשופרת של הספר, שממנו הטכניקה הזו היא קטע: www.20thingsilearned.com. שם תוכלו לראות איך אפשר להחיל את הפיכת הדפים באפליקציה אמיתית ואיך היא נהיית חזקה יותר כשמשלבים אותה עם תכונות HTML5 אחרות.
קובצי עזר
- מפרט ה-API של Canvas