A Pen by Cole Wintringham on CodePen.
Created
February 1, 2023 22:14
-
-
Save cerebralpolicy/cf907ab509a862f400e5d05d36c2d2af to your computer and use it in GitHub Desktop.
Persona 5 SMS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div id="chat"></div> | |
<script id="chat-thread" type="x-template"> | |
<div v-if="window.devicePixelRatio === 1"> | |
<br/> | |
<ChatMessage | |
v-for="message in messages" | |
ref="chatMessages" | |
:message="message.text" | |
:remote="message.remote"/> | |
</div> | |
<div v-else style="margin: 20px; color: white"> | |
<h1>Oops, my bad...</h1> | |
<p> | |
Your <code>window.devicePixelRatio</code> does not equal <code>1</code>. This means that the ratio between physical pixels and logical pixels don't have a 1-to-1 correspondence on your display device. I have yet to (and probably never will) implement pixel scaling for these ratios. If you're on a mobile device, try viewing this on a desktop computer; but not on a Retina or HiDPI display. | |
</p> | |
<p>- ナッティ (nutty7t)</p> | |
</div> | |
</script> | |
<script id="chat-message" type="x-template"> | |
<svg xmlns="http://www.w3.org/2000/svg" :viewBox="`0 0 500 ${viewBoxHeight}`" :style="style"> | |
<!-- | |
HACK: This invisible <text> element is used to figure out | |
the bounding box of a line of text without it being painted. | |
--> | |
<text | |
ref="hackText" | |
visibility="hidden" | |
:style="{ fontSize: fontSize + 'px' }">{{ hackText }}</text> | |
<!-- Buddy Avatar --> | |
<polygon | |
v-if="remote" | |
points="-10,-5 62,5 70,55 5,63" | |
:transform="`translate(30,${containerHeight / 2 + messageBox.origin.y - 25})`" | |
style="fill: black"/> | |
<polygon | |
v-if="remote" | |
points="0,0 60,10 70,50 10,60" | |
:transform="`translate(30,${containerHeight / 2 + messageBox.origin.y - 25})`" | |
style="fill: white"/> | |
<clipPath id="avatarClipPath"> | |
<polygon points="2,-10 62,-10 100,42 10,58"/> | |
</clipPath> | |
<image | |
clip-path="url(#avatarClipPath)" | |
v-if="remote" | |
x="-10" | |
y="-10" | |
xlink:href="https://static.wikia.nocookie.net/megamitensei/images/c/c9/Sumire_Text_Icon.png" | |
:transform="`translate(30,${containerHeight / 2 + messageBox.origin.y - 25})`" | |
width="80px"/> | |
<!-- Message Text Container Border --> | |
<polygon | |
:points="containerBorderPoints" | |
:style="{ fill: primaryColor }" | |
:class="{ flipX: !remote }"/> | |
<!-- Message Text Container Tail Border --> | |
<polygon | |
:points="containerTailBorderPoints" | |
:style="{ fill: primaryColor }" | |
:class="{ flipX: !remote }"/> | |
<!-- Message Text Container Tail --> | |
<polygon | |
:points="containerTailPoints" | |
:style="{ fill: secondaryColor }" | |
:class="{ flipX: !remote }"/> | |
<!-- Message Text Container --> | |
<polygon | |
:points="containerPoints" | |
:style="{ fill: secondaryColor }" | |
:class="{ flipX: !remote }"/> | |
<!-- Message Text --> | |
<text :y="textOffset.y" :style="{ fontSize: fontSize + 'px' }"> | |
<tspan | |
v-for="line of wrappedMessage" | |
:x="remote ? messageBox.origin.x + textOffset.x : 500 - messageBox.origin.x - messageBox.centerWidth" | |
:dy="`${lineHeight}em`" | |
:style="{ fill: primaryColor }"> | |
{{ line.text }} | |
</tspan> | |
</text> | |
</svg> | |
</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const ChatMessage = { | |
template: '#chat-message', | |
props: { | |
message: { | |
type: String, | |
required: true | |
}, | |
remote: { | |
// Does the message originate | |
// from a remote source? | |
type: Boolean, | |
default: false | |
}, | |
fontSize: { | |
type: Number, | |
default: 14 | |
}, | |
lineHeight: { | |
type: Number, | |
default: 1.5 // em | |
} | |
}, | |
data () { | |
return { | |
hackText: '', | |
style: { | |
opacity: 0 | |
} | |
} | |
}, | |
computed: { | |
// ------------------------------------------ | |
// Message Box (remote: true) | |
// ------------------------------------------ | |
// | |
// origin x - right width | |
// \ [ ---- center width ---- ] | | |
// x----------------------- x --- x | |
// / | <message text> | / | |
// / | | / | |
// x --- x ---------------------- x | |
// | | |
// + - left width | |
// | |
messageBox () { | |
return { | |
origin: { | |
x: this.remote ? 130 : 60, | |
y: 20 | |
}, | |
centerWidth: 300, | |
leftWidth: 10, | |
rightWidth: 20, | |
slantHeight: 5, | |
border: { | |
normal: 4, | |
left: 15, | |
right: 35 | |
} | |
} | |
}, | |
textOffset () { | |
return { | |
// Left padding. | |
x: 15, | |
// Adjust for top/bottom padding. | |
y: this.messageBox.origin.y + this.fontSize * this.lineHeight / 4 | |
} | |
}, | |
containerPoints () { | |
return [ | |
{ | |
x: this.messageBox.origin.x, | |
y: this.messageBox.origin.y | |
}, | |
{ | |
x: this.messageBox.origin.x + this.messageBox.centerWidth + this.messageBox.rightWidth, | |
y: this.messageBox.origin.y | |
}, | |
{ | |
x: this.messageBox.origin.x + this.messageBox.centerWidth, | |
y: this.messageBox.origin.y + this.containerHeight + this.messageBox.slantHeight | |
}, | |
{ | |
x: this.messageBox.origin.x - this.messageBox.leftWidth, | |
y: this.messageBox.origin.y + this.containerHeight | |
} | |
].map(p => `${p.x},${p.y}`).join(' ') | |
}, | |
containerBorderPoints () { | |
return [ | |
{ | |
x: this.messageBox.origin.x - this.messageBox.border.normal, | |
y: this.messageBox.origin.y - this.messageBox.border.normal | |
}, | |
{ | |
x: this.messageBox.origin.x + this.messageBox.centerWidth + this.messageBox.border.right, | |
y: this.messageBox.origin.y - this.messageBox.border.normal | |
}, | |
{ | |
x: this.messageBox.origin.x + this.messageBox.centerWidth + this.messageBox.border.normal, | |
y: this.messageBox.origin.y + this.containerHeight + this.messageBox.border.normal + this.messageBox.slantHeight | |
}, | |
{ | |
x: this.messageBox.origin.x - this.messageBox.border.left, | |
y: this.messageBox.origin.y + this.containerHeight + this.messageBox.border.normal | |
} | |
].map(p => `${p.x},${p.y}`).join(' ') | |
}, | |
containerTailPoints () { | |
return [ | |
{ | |
x: this.messageBox.origin.x - 33, | |
y: this.messageBox.origin.y + this.containerHeight / 2 + 8 | |
}, | |
{ | |
x: this.messageBox.origin.x - 17, | |
y: this.messageBox.origin.y + this.containerHeight / 2 - 10 | |
}, | |
{ | |
x: this.messageBox.origin.x - 12, | |
y: this.messageBox.origin.y + this.containerHeight / 2 - 4 | |
}, | |
{ | |
x: this.messageBox.origin.x, | |
y: this.messageBox.origin.y + this.containerHeight / 2 - 10 | |
}, | |
{ | |
x: this.messageBox.origin.x, | |
y: this.messageBox.origin.y + this.containerHeight / 2 + 5 | |
}, | |
{ | |
x: this.messageBox.origin.x - 18, | |
y: this.messageBox.origin.y + this.containerHeight / 2 + 10 | |
}, | |
{ | |
x: this.messageBox.origin.x - 22, | |
y: this.messageBox.origin.y + this.containerHeight / 2 + 5 | |
} | |
].map(p => `${p.x},${p.y}`).join(' ') | |
}, | |
containerTailBorderPoints () { | |
return [ | |
{ | |
x: this.messageBox.origin.x - 40, | |
y: this.messageBox.origin.y + this.containerHeight / 2 + 12 | |
}, | |
{ | |
x: this.messageBox.origin.x - 15, | |
y: this.messageBox.origin.y + this.containerHeight / 2 - 16 | |
}, | |
{ | |
x: this.messageBox.origin.x - 12, | |
y: this.messageBox.origin.y + this.containerHeight / 2 - 10 | |
}, | |
{ | |
x: this.messageBox.origin.x, | |
y: this.messageBox.origin.y + this.containerHeight / 2 - 15 | |
}, | |
{ | |
x: this.messageBox.origin.x, | |
y: this.messageBox.origin.y + this.containerHeight / 2 + 10 | |
}, | |
{ | |
x: this.messageBox.origin.x - 20, | |
y: this.messageBox.origin.y + this.containerHeight / 2 + 15 | |
}, | |
{ | |
x: this.messageBox.origin.x - 24, | |
y: this.messageBox.origin.y + this.containerHeight / 2 + 10 | |
} | |
].map(p => `${p.x},${p.y}`).join(' ') | |
}, | |
containerHeight () { | |
// Compute how much vertical space the message text takes up by | |
// multiplying the line height by the number of lines in the message. | |
let height = this.fontSize * this.lineHeight * this.wrappedMessage.length | |
// Now, we need to add some extra bottom padding otherwise the | |
// descenders (the part of the characters beneath the baseline) | |
// will get clipped. I don't know the exact height of the descender, | |
// but I figure that 1/2 em should be fine. And then we'll add another | |
// 1/4 em for top and bottom paddings (1/2 em in total). | |
// | |
// --- | |
// | top padding (1/4 em) | |
// --- | |
// | text height (line height * # of lines) | |
// --- | |
// | descender padding (1/2 em) | |
// --- | |
// . | slanted bottom edge (this.messageBox.slantHeight) | |
// --- | |
// | bottom padding (1/4 em) | |
// --- | |
// | |
return height + this.fontSize * this.lineHeight | |
}, | |
viewBoxHeight () { | |
// | |
// --- | |
// | border width | |
// --- | |
// | container height | |
// --- | |
// | border width | |
// --- | |
// | |
return this.containerHeight + this.messageBox.origin.y * 2 | |
}, | |
primaryColor () { | |
return this.remote ? 'white' : 'black' | |
}, | |
secondaryColor () { | |
return this.remote ? 'black' : 'white' | |
} | |
}, | |
asyncComputed: { | |
wrappedMessage: { | |
async get () { | |
// Kind of a hacky way of implementing word wrapping | |
// on SVG <text> elements. Not quite sure how to go | |
// about determining the bounding box of some text, | |
// without actually rendering it on the DOM. | |
const words = this.message.split(/\s+/) | |
const lines = [] | |
let line = [] | |
while (words.length > 0) { | |
line.push(words.shift()) | |
this.hackText = line.join(' ') | |
if (await this.hackTextWidth() > this.messageBox.centerWidth) { | |
words.unshift(line.pop()) | |
lines.push({ text: line.join(' ') }) | |
line = [] | |
} | |
} | |
lines.push({ text: line.join(' ') }) | |
if (lines.length === 1) { | |
// Messages that are only one line have a fluid width. | |
this.messageBox.centerWidth = await this.hackTextWidth() + this.textOffset.x * 2 | |
} | |
return lines | |
}, | |
default: [] | |
} | |
}, | |
methods: { | |
async hackTextWidth () { | |
// Wait until #hackText is rendered in the DOM. | |
while (!this.$refs.hackText) { | |
await Vue.nextTick() | |
} | |
// Wait for Vue to update the innerHTML of #hackText. | |
await Vue.nextTick() | |
if (this.$refs.hackText.innerHTML === this.hackText) { | |
return this.$refs.hackText.clientWidth | |
} else { | |
console.log( | |
`[error] hackText does not have expected text\n` + | |
` expected: "${this.hackText}"\n` + | |
` actual: "${this.$refs.hackText.innerHTML}"` | |
) | |
return 0 | |
} | |
} | |
}, | |
mounted () { | |
TweenMax.to(this.style, 1, { | |
opacity: 1, | |
ease: Power3.easeOut | |
}) | |
} | |
} | |
const ChatThread = new Vue({ | |
el: '#chat', | |
template: '#chat-thread', | |
components: { ChatMessage }, | |
data () { | |
return { | |
messages: [], | |
queue: [ | |
{ | |
text: 'Hello world!', | |
remote: true | |
}, | |
{ | |
text: 'I am thou, thou art I... Thou hast acquired a new vow. It shall become the wings of rebellion that breaketh thy chains of captivity. With the birth of the Nutty Persona, I have obtained the winds of blessing that shall lead to freedom and new power...', | |
remote: true | |
}, | |
{ | |
text: 'Whoooaaa! Looking cool, Joker!', | |
remote: false | |
}, | |
{ | |
text: 'Want to head over to the palace today?', | |
remote: false | |
}, | |
{ | |
text: 'Sure!', | |
remote: true | |
}, | |
{ | |
text: 'Kamoshida needs to atone for his actions...', | |
remote: true | |
}, | |
{ | |
text: 'If we steal his treasure, his heart will change in the real world. We can make him confess his sins.', | |
remote: false | |
} | |
], | |
interval: undefined | |
} | |
}, | |
watch: { | |
async messages (newMessages, oldMessages) { | |
if (this.queue.length === 0) { | |
clearInterval(this.interval) | |
} | |
await Vue.nextTick() | |
const messages = this.$refs.chatMessages | |
const lastMessage = messages[messages.length - 1] | |
// TODO: Draw the message trail. | |
if (document.body !== null) { | |
TweenMax.to(window, 1, { scrollTo: { y: document.body.scrollHeight }, ease: Power3.easeOut }); | |
} | |
} | |
}, | |
mounted () { | |
this.interval = setInterval(() => { | |
this.messages.push(this.queue.shift()) | |
}, 2000) | |
} | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.10/vue.min.js"></script> | |
<script src="https://unpkg.com/vue-async-computed@3.7.0"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/2.1.3/TweenMax.min.js"></script> | |
<script src="//cdnjs.cloudflare.com/ajax/libs/gsap/latest/plugins/ScrollToPlugin.min.js"></script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import url('https://fonts.googleapis.com/css?family=Source+Sans+Pro&display=swap'); | |
html { | |
background: #cc0000; | |
font-family: 'Source Sans Pro', sans-serif; | |
font-weight: 700; | |
} | |
body { | |
max-width: 600px; | |
margin: auto; | |
} | |
.flipX { | |
transform: scaleX(-1); | |
transform-origin: center; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment