kreftjs 0.0.20-beta → 0.0.22-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/bin/artisan.js +4 -2
- package/bin/backend/cache.js +31 -5
- package/bin/backend/gc.js +193 -43
- package/bin/backend/http.js +6 -2
- package/bin/backend/index.js +6 -0
- package/bin/backend/mcp-client.js +133 -0
- package/bin/backend/mcp.js +17 -0
- package/bin/backend/model.js +15 -1
- package/bin/backend/shield.js +266 -0
- package/bin/backend/stream.js +74 -0
- package/bin/build.js +12 -5
- package/bin/db-api.js +9 -1
- package/bin/kreftjs.js +50 -12
- package/book/ar/16-virtual-list.md +131 -0
- package/book/ar/17-combobox.md +169 -0
- package/book/ar/18-tabs-offline.md +168 -0
- package/book/ar/19-wizard.md +196 -0
- package/book/ar/20-pipes-match-props.md +75 -0
- package/book/ar/README.md +12 -1
- package/book/kreft-book-ar.docx +0 -0
- package/docs/README.md +209 -13
- package/editors/sublime/Comments.tmPreferences +26 -0
- package/editors/sublime/Component.sublime-snippet +20 -0
- package/editors/sublime/Kreft.sublime-syntax +39 -0
- package/editors/sublime/LICENSE +95 -0
- package/editors/sublime/README.md +7 -0
- package/editors/sublime/action.sublime-snippet +10 -0
- package/editors/sublime/derive.sublime-snippet +8 -0
- package/editors/sublime/for.sublime-snippet +12 -0
- package/editors/sublime/k-case.sublime-snippet +8 -0
- package/editors/sublime/k-if.sublime-snippet +8 -0
- package/editors/sublime/k-match.sublime-snippet +12 -0
- package/editors/sublime/kreft-wait.sublime-snippet +12 -0
- package/editors/sublime/layout.sublime-snippet +8 -0
- package/editors/sublime/load.sublime-snippet +8 -0
- package/editors/sublime/on.sublime-snippet +10 -0
- package/editors/sublime/pipe.sublime-snippet +8 -0
- package/editors/sublime/prop-typed.sublime-snippet +8 -0
- package/editors/sublime/saved.sublime-snippet +8 -0
- package/editors/sublime/section.sublime-snippet +10 -0
- package/editors/sublime/state.sublime-snippet +8 -0
- package/editors/sublime/watch.sublime-snippet +10 -0
- package/editors/sublime/when.sublime-snippet +10 -0
- package/editors/sublime/yield.sublime-snippet +8 -0
- package/editors/vscode/snippets/kreftjs.json +143 -15
- package/editors/zed/LICENSE +95 -0
- package/editors/zed/README.md +17 -0
- package/editors/zed/extension.toml +11 -0
- package/editors/zed/languages/kreft/brackets.scm +4 -0
- package/editors/zed/languages/kreft/config.toml +17 -0
- package/editors/zed/languages/kreft/highlights.scm +18 -0
- package/editors/zed/languages/kreft/indents.scm +5 -0
- package/editors/zed/languages/kreft/injections.scm +14 -0
- package/editors/zed/languages/kreft/outline.scm +3 -0
- package/kreftjs.d.ts +1 -1
- package/kreftjs.js +219 -14
- package/package.json +3 -2
- package/server.d.ts +44 -0
- package/templates/app/backend/app/Auth/Session.js +8 -4
- package/templates/app/backend/app/Http/Middleware/Authenticate.js +7 -4
- package/templates/app/backend/database/seeders/DatabaseSeeder.js +4 -0
- package/templates/app/frontend/kreftjs.d.ts +1 -1
- package/templates/app/frontend/kreftjs.js +219 -14
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# الفصل السادس عشر (خبير): قوائم ضخمة بلا تباطؤ — النافذة الافتراضية
|
|
2
|
+
|
|
3
|
+
عرض 1,000 صف سهل. عرض 100,000 صف في جدول واحد يغرق المتصفح: بناء عشرات آلاف العقد في الـ DOM يكلف وقتًا وذاكرة. الحل الذي تستعمله كل الجداول الاحترافية هو **النافذة الافتراضية (Virtualization)**: نرسم الصفوف المرئية فقط، وعند التمرير نستبدل الصفوف التي خرجت من الشاشة.
|
|
4
|
+
|
|
5
|
+
سنبني هنا مكوّن `VirtualList` عامًا يتحمل أي عدد من العناصر ويبقى التمرير سلسًا. **كل الشيفرة جرِّبت في متصفح** مع 100,000 عنصر.
|
|
6
|
+
|
|
7
|
+
## 1) الفكرة
|
|
8
|
+
|
|
9
|
+
بدل أن نرسم القائمة كلها:
|
|
10
|
+
|
|
11
|
+
1. نعرف ارتفاع الصف الواحد `itemHeight` (يجب أن يكون ثابتًا).
|
|
12
|
+
2. من موضع التمرير `scrollTop` وارتفاع الحاوية نعرف **أي نطاق من العناصر ظاهر**.
|
|
13
|
+
3. نرسم ذلك النطاق فقط، فوق نطاق صغير إضافي (`overscan`) كي لا يظهر فراغ عند التمرير السريع.
|
|
14
|
+
4. نضبط ارتفاع "العمود الفارغ" على `items.length * itemHeight` ليبقى شريط التمرير صادقًا، ونزيح النافذة المرئية بـ `translateY`.
|
|
15
|
+
|
|
16
|
+
## 2) المكوّن
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<!-- components/VirtualList.kreft -->
|
|
20
|
+
prop items = [] // كل العناصر (قد تكون ملايين)
|
|
21
|
+
prop itemHeight = 48 // ارتفاع الصف الواحد بالبكسل
|
|
22
|
+
prop overscan = 6 // صفوف إضافية فوق وتحت النافذة المرئية
|
|
23
|
+
|
|
24
|
+
state viewport = null // يملؤه k-ref
|
|
25
|
+
state scrollTop = 0
|
|
26
|
+
state viewH = 0
|
|
27
|
+
|
|
28
|
+
// ارتفاع العمود الكامل (شريط التمرير يعتمد عليه)
|
|
29
|
+
derive total = items.length * itemHeight
|
|
30
|
+
|
|
31
|
+
// النطاق المرئي + هامش الأمان
|
|
32
|
+
derive start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
|
|
33
|
+
derive end = Math.min(items.length, Math.ceil((scrollTop + viewH) / itemHeight) + overscan)
|
|
34
|
+
derive visible = items.slice(start, end)
|
|
35
|
+
derive offset = start * itemHeight
|
|
36
|
+
|
|
37
|
+
action onScroll() {
|
|
38
|
+
scrollTop = viewport.scrollTop
|
|
39
|
+
viewH = viewport.clientHeight
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// نراقب حجم الحاوية (نافذة صغرت أو كبرت) وننظف حين يزول المكوّن
|
|
43
|
+
let ro
|
|
44
|
+
on ready {
|
|
45
|
+
viewH = viewport.clientHeight
|
|
46
|
+
ro = new ResizeObserver(() => { viewH = viewport.clientHeight })
|
|
47
|
+
ro.observe(viewport)
|
|
48
|
+
}
|
|
49
|
+
on destroy { ro && ro.disconnect() }
|
|
50
|
+
|
|
51
|
+
<view>
|
|
52
|
+
<div class="vlist" k-ref="viewport" @scroll="onScroll">
|
|
53
|
+
<div class="vlist__sizer" style="height: {total}px">
|
|
54
|
+
<div class="vlist__window" style="transform: translateY({offset}px)">
|
|
55
|
+
{for (item, i) in visible key item.id}
|
|
56
|
+
<div class="vlist__row" style="height: {itemHeight}px">
|
|
57
|
+
<slot item={item} index={start + i}>{item}</slot>
|
|
58
|
+
</div>
|
|
59
|
+
{/for}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</view>
|
|
64
|
+
|
|
65
|
+
<style scoped>
|
|
66
|
+
.vlist { overflow-y: auto; height: 400px; border: 1px solid var(--border, #ddd); }
|
|
67
|
+
.vlist__sizer { position: relative; }
|
|
68
|
+
.vlist__window { position: absolute; inset-block-start: 0; inset-inline: 0; }
|
|
69
|
+
.vlist__row { box-sizing: border-box; }
|
|
70
|
+
</style>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
لماذا يعمل هذا؟
|
|
74
|
+
|
|
75
|
+
- **`derive visible`** يُعاد حسابه وحده كلما تغيّر `scrollTop` أو `viewH` أو `items`، فيرسم Kreft الصفوف الجديدة ويزيل الخارجة (وهو يعرف كيف يطابقها بمفتاح `key item.id`).
|
|
76
|
+
- **`translateY`** على نافذة الصفوف أرخص من `padding-top` كبير: لا نعيد حساب التخطيط.
|
|
77
|
+
- **`k-ref`** يضع عنصر التمرير في `state viewport`، فنقرؤه في `onScroll` بلا بحث في الـ DOM كل مرة.
|
|
78
|
+
- **`ResizeObserver`** يلتقط تغيّر حجم الحاوية، و`on destroy` يقطع المراقبة.
|
|
79
|
+
|
|
80
|
+
## 3) الاستعمال: 100,000 صف
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<!-- pages/BigTable.kreft -->
|
|
84
|
+
import VirtualList from '../components/VirtualList.kreft'
|
|
85
|
+
|
|
86
|
+
state rows = Array.from({ length: 100000 }, (_, i) => ({
|
|
87
|
+
id: i + 1,
|
|
88
|
+
name: 'الاسم ' + (i + 1),
|
|
89
|
+
price: (i % 100) + 1,
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
<view>
|
|
93
|
+
<h1>100,000 صف، ورسم فوري</h1>
|
|
94
|
+
<virtual-list items={rows} itemHeight={44}>
|
|
95
|
+
<template k-slot="{ item, index }">
|
|
96
|
+
<div style="display:flex; gap:12px; align-items:center; height:100%; padding-inline:12px">
|
|
97
|
+
<b>{index + 1}</b>
|
|
98
|
+
<span>{item.name}</span>
|
|
99
|
+
<span style="margin-inline-start:auto">{item.price} $</span>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
|
102
|
+
</virtual-list>
|
|
103
|
+
</view>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- الفتحة الافتراضية هنا **بمعاملات**: `k-slot="{ item, index }"` يفكّك ما مرّره `<slot item={item} index={start + i}>`.
|
|
107
|
+
- `index` هو الرقم الحقيقي في القائمة (لأننا أضفنا `start`) لا رقم الصف المرسوم.
|
|
108
|
+
|
|
109
|
+
## 4) لمسات أداء تكميلية
|
|
110
|
+
|
|
111
|
+
النافذة الافتراضية وحدها تكفي غالبًا. هذه أدوات إضافية للصفوف الثقيلة:
|
|
112
|
+
|
|
113
|
+
| الأداة | متى تستعملها | مثال |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `k-once` | صف/خلية لا تتغير أبدًا بعد أول رسم | `<td k-once>{row.id}</td>` |
|
|
116
|
+
| `k-memo` | خلية تعتمد على قيمتين فقط | `<tr k-memo="[row.id === selected]">` |
|
|
117
|
+
| `key item.id` | دائمًا مع الحلقات | `{for item in visible key item.id}` |
|
|
118
|
+
| `derive` للقيم الغالية | احسب مرة لا في كل خلية | `derive priceLabel = (p) => p.toFixed(2) + ' $'` |
|
|
119
|
+
| سكربت عادي لما لا تحتاجه الصفحة | ثوابت ودوال مساعدة | `const clamp = (n, a, b) => …` |
|
|
120
|
+
|
|
121
|
+
تذكّر فخ الفصل الثالث عشر: ما تكتبه بـ `const`/`let` لا تراه الصفحة. كل ما يظهر في `view` يجب أن يكون `state` أو `derive` أو `action` أو `prop`.
|
|
122
|
+
|
|
123
|
+
## 5) القياس
|
|
124
|
+
|
|
125
|
+
جرّب الفرق بنفسك: اعرض 100,000 عنصر بدون النافذة الافتراضية (حلقة عادية) ثم معها، وراقب زمن أول رسم وذاكرة التبويب في أدوات المطوّر. في تجربتنا كانت الحلقة العادية تُرسم في مئات المللي ثانية وتشبع الذاكرة، بينما النافذة الافتراضية تُبقي في الـ DOM نحو 30 صفًا فقط مهما كان حجم القائمة.
|
|
126
|
+
|
|
127
|
+
## تمارين
|
|
128
|
+
|
|
129
|
+
1. أضف `prop itemKey` ليستعمل المفتاح `item[itemKey]` بدل `item.id` دائمًا.
|
|
130
|
+
2. اجعل `itemHeight` متغيّرًا: صفوف بارتفاعات مختلفة تُجمَع في "أقسام" (Variable size virtualization).
|
|
131
|
+
3. أضف تمريرًا إلى عنصر معيّن: دالة `scrollToIndex(i)` تضبط `viewport.scrollTop = i * itemHeight`.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# الفصل السابع عشر (خبير): مكوّن مركّب — قائمة اختيار ذكية (Combobox)
|
|
2
|
+
|
|
3
|
+
المكوّنات البسيطة تأخذ `props` وتعرض HTML. المكوّنات **المركّبة** تحزم عدة قطع تتعاون معًا: زناد وفتحة منبثقة وحقل بحث وقائمة خيارات ولوحة مفاتيح. هنا نبني `Combobox` كاملًا قابلًا لإعادة الاستخدام: يختار من قائمة، يبحث، يتنقّل بالأسهم، يغلق بـ Esc أو بالنقر خارجه، ويعمل بـ `k-model`.
|
|
4
|
+
|
|
5
|
+
**جرِّب هذه الشيفرة في المتصفح** كما هي.
|
|
6
|
+
|
|
7
|
+
## 1) المكوّن
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<!-- components/Combobox.kreft -->
|
|
11
|
+
prop modelValue = null // القيمة المختارة (id)
|
|
12
|
+
prop options = [] // [{ id, label }, …]
|
|
13
|
+
prop placeholder = 'اختر…'
|
|
14
|
+
prop searchable = false
|
|
15
|
+
event update:modelValue
|
|
16
|
+
|
|
17
|
+
state open = false
|
|
18
|
+
state query = ''
|
|
19
|
+
state active = 0 // فهرس الخيار المظلَّل
|
|
20
|
+
state trigger = null // يملؤه k-ref
|
|
21
|
+
state rect = { top: 0, left: 0, width: 0 }
|
|
22
|
+
|
|
23
|
+
derive selected = options.find((o) => o.id === modelValue) || null
|
|
24
|
+
derive filtered = !searchable || !query ? options : options.filter((o) => o.label.includes(query))
|
|
25
|
+
derive shown = filtered.slice(0, 50) // لا نرسم أكثر من 50
|
|
26
|
+
|
|
27
|
+
action toggle() {
|
|
28
|
+
if (!open && trigger) {
|
|
29
|
+
const r = trigger.getBoundingClientRect()
|
|
30
|
+
rect = { top: r.bottom, left: r.left, width: r.width }
|
|
31
|
+
}
|
|
32
|
+
open = !open
|
|
33
|
+
active = 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
action pick(o) {
|
|
37
|
+
send('update:modelValue', o.id)
|
|
38
|
+
open = false
|
|
39
|
+
query = ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
action onKey(e) {
|
|
43
|
+
if (e.key === 'Escape') { open = false; return }
|
|
44
|
+
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { toggle(); return }
|
|
45
|
+
if (!open) return
|
|
46
|
+
if (e.key === 'ArrowDown') active = Math.min(active + 1, shown.length - 1)
|
|
47
|
+
else if (e.key === 'ArrowUp') active = Math.max(active - 1, 0)
|
|
48
|
+
else if (e.key === 'Enter') { if (shown[active]) pick(shown[active]) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
<view>
|
|
52
|
+
<div class="combobox" tabindex="0" @keydown="onKey($event)" k-click-outside={() => open = false}>
|
|
53
|
+
<button type="button" class="combobox__trigger" k-ref="trigger" @click="toggle" aria-haspopup="listbox">
|
|
54
|
+
<span>{selected ? selected.label : placeholder}</span> <span>▾</span>
|
|
55
|
+
</button>
|
|
56
|
+
|
|
57
|
+
<kreft-portal to="body">
|
|
58
|
+
<div k-if="open" class="combobox__menu" role="listbox" k-animate="scale"
|
|
59
|
+
style="position:fixed; top:{rect.top}px; left:{rect.left}px; width:{rect.width}px">
|
|
60
|
+
<input k-if="searchable" class="combobox__search" k-model="query" placeholder="ابحث…">
|
|
61
|
+
<ul>
|
|
62
|
+
{for (o, i) in shown key o.id}
|
|
63
|
+
<li role="option" class={i === active ? 'active' : ''}
|
|
64
|
+
@mouseenter="active = i" @click="pick(o)">
|
|
65
|
+
<slot name="option" option={o}>{o.label}</slot>
|
|
66
|
+
</li>
|
|
67
|
+
{empty}
|
|
68
|
+
<li class="combobox__empty">لا نتائج</li>
|
|
69
|
+
{/for}
|
|
70
|
+
</ul>
|
|
71
|
+
</div>
|
|
72
|
+
</kreft-portal>
|
|
73
|
+
</div>
|
|
74
|
+
</view>
|
|
75
|
+
|
|
76
|
+
<style scoped>
|
|
77
|
+
.combobox { position: relative; }
|
|
78
|
+
.combobox__trigger { width: 100%; display: flex; justify-content: space-between; gap: 8px; }
|
|
79
|
+
.combobox__menu { background: var(--background, #fff); color: var(--foreground, #111);
|
|
80
|
+
border: 1px solid var(--border, #ddd); max-height: 240px; overflow-y: auto; z-index: 200; }
|
|
81
|
+
.combobox__search { width: 100%; border: 0; border-block-end: 1px solid var(--border, #ddd); padding: 8px; }
|
|
82
|
+
.combobox__menu ul { list-style: none; margin: 0; padding: 4px; }
|
|
83
|
+
.combobox__menu li { padding: 6px 8px; cursor: pointer; border-radius: 4px; }
|
|
84
|
+
.combobox__menu li.active { background: var(--accent, #8883); }
|
|
85
|
+
.combobox__empty { padding: 8px; opacity: .6; }
|
|
86
|
+
</style>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
النقاط المهمة:
|
|
90
|
+
|
|
91
|
+
- **`k-model` على مكوّن** (كما في الفصل الثالث): المكوّن يملك `prop modelValue` ويرسل `update:modelValue`، فيكتب الأب `<combobox k-model="city">`.
|
|
92
|
+
- **`k-click-outside`** توجيه مخصص من الفصل الثالث عشر يغلق القائمة عند النقر خارجها.
|
|
93
|
+
- **`kreft-portal`** يرسم القائمة في `<body>` فلا يقتطعها `overflow` لآب؛ ولذلك نحسب موضعها يدويًا من `trigger.getBoundingClientRect()`.
|
|
94
|
+
- **فتحة `option` بمعاملات**: من يستعمل المكوّن يقرر شكل كل خيار.
|
|
95
|
+
- **`shown = filtered.slice(0, 50)`**: حد أقصى للرسم مهما كثرت النتائج (تكمّله البحث).
|
|
96
|
+
- **`k-animate="scale"`** لحركة دخول وخروج خفيفة.
|
|
97
|
+
|
|
98
|
+
## 2) الاستعمال
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<!-- pages/City.kreft -->
|
|
102
|
+
import Combobox from '../components/Combobox.kreft'
|
|
103
|
+
|
|
104
|
+
state cities = [
|
|
105
|
+
{ id: 'mo', label: 'مقديشو' },
|
|
106
|
+
{ id: 'hr', label: 'هرجيسا' },
|
|
107
|
+
{ id: 'bos', label: 'بوصاصو' },
|
|
108
|
+
{ id: 'ki', label: 'كيسمايو' },
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
state city = null
|
|
112
|
+
|
|
113
|
+
<view>
|
|
114
|
+
<p>المدينة المختارة: {cities.find((c) => c.id === city)?.label || '—'}</p>
|
|
115
|
+
<combobox k-model="city" options={cities} searchable placeholder="اختر مدينة">
|
|
116
|
+
<template k-slot:option="{ option }">
|
|
117
|
+
<b>{option.label}</b> <small style="opacity:.6">{option.id.toUpperCase()}</small>
|
|
118
|
+
</template>
|
|
119
|
+
</combobox>
|
|
120
|
+
</view>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## 3) خيارات تُحمَّل من الخادم (غير متزامن)
|
|
124
|
+
|
|
125
|
+
لن تفيدك القائمة الثابتة في تطبيق حقيقي. حوّل البحث إلى طلب خادم بتأخير بسيط، كما فعلنا في بحث المدونة:
|
|
126
|
+
|
|
127
|
+
```html
|
|
128
|
+
<!-- components/AsyncCombobox.kreft -->
|
|
129
|
+
prop modelValue = null
|
|
130
|
+
event update:modelValue
|
|
131
|
+
|
|
132
|
+
state open = false
|
|
133
|
+
state text = ''
|
|
134
|
+
state query = '' // ما نبحث به فعلًا (بعد التأخير)
|
|
135
|
+
|
|
136
|
+
let timer
|
|
137
|
+
watch text {
|
|
138
|
+
clearTimeout(timer)
|
|
139
|
+
timer = setTimeout(() => { query = value }, 300)
|
|
140
|
+
}
|
|
141
|
+
on destroy { clearTimeout(timer) }
|
|
142
|
+
|
|
143
|
+
// يعيد الجلب وحده كلما تغيّر query
|
|
144
|
+
load results = api.get('/api/cities', { query: { q: query || undefined } })
|
|
145
|
+
|
|
146
|
+
action pick(c) { send('update:modelValue', c.id); open = false; text = c.label }
|
|
147
|
+
|
|
148
|
+
<view>
|
|
149
|
+
<input k-model="text" @focus="open = true" placeholder="ابحث عن مدينة…"
|
|
150
|
+
k-click-outside={() => open = false}>
|
|
151
|
+
<div k-if="open" class="menu">
|
|
152
|
+
<p k-if="results.loading">جارٍ البحث…</p>
|
|
153
|
+
{for c in results.data || [] key c.id}
|
|
154
|
+
<button @click="pick(c)">{c.label}</button>
|
|
155
|
+
{empty}
|
|
156
|
+
<p>لا نتائج لـ "{query}".</p>
|
|
157
|
+
{/for}
|
|
158
|
+
</div>
|
|
159
|
+
</view>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- **`text` و`query`**: نفصل ما يكتبه المستخدم عمّا نطلبه، فيتجمع الإدخال السريع في طلب واحد.
|
|
163
|
+
- **`load results`** يقرأ `query` فيجلب من جديد عند تغيّرها، ويعطيك `loading` و`error` و`reload()`.
|
|
164
|
+
|
|
165
|
+
## تمارين
|
|
166
|
+
|
|
167
|
+
1. أضف إلى `Combobox` خاصية `prop clearable` مع زر ✕ يفرغ الاختيار.
|
|
168
|
+
2. اجعل القائمة تفتح للأعلى إذا لم يتسع أسفلها (قارن `rect.top` بارتفاع النافذة).
|
|
169
|
+
3. حوّل `AsyncCombobox` ليدعم اختيارًا متعددًا: `modelValue` مصفوفة و`pick` يبدّل العنصر بدل اختياره وحيدًا.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# الفصل الثامن عشر (خبير): مزامنة بين التبويبات والعمل دون اتصال
|
|
2
|
+
|
|
3
|
+
Kreft يحفظ الحالة المشتركة بين التبويبات **تلقائيًا** (راجع `saved` في الفصل الثالث): غيّر قيمة في تبويب فتراها في الآخر. هنا نبني فوق ذلك طبقتين احترافيتين:
|
|
4
|
+
|
|
5
|
+
1. **حضور (Presence):** من المفتوح الآن؟ رسائل حيّة بين التبويبات عبر `BroadcastChannel`.
|
|
6
|
+
2. **طابور دون اتصال (Offline queue):** الأوامر تُحفظ وتُرسل عند عودة الاتصال، دون أن يفقد المستخدم ما كتبه.
|
|
7
|
+
|
|
8
|
+
**الشيفرة جُرِّبت** بفتح تبويبين وحجب الشبكة.
|
|
9
|
+
|
|
10
|
+
## 1) المنطق المشترك: الحضور بين التبويبات
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
// logic/tabs.js — من مفتوح الآن في "الغرفة"؟
|
|
14
|
+
export function usePresence(room = 'default') {
|
|
15
|
+
const channel = new BroadcastChannel('kreft-' + room)
|
|
16
|
+
const me = Math.random().toString(36).slice(2, 8)
|
|
17
|
+
const s = Kreftjs.state({ peers: [] })
|
|
18
|
+
|
|
19
|
+
const ping = () => channel.postMessage({ type: 'ping', id: me, at: Date.now() })
|
|
20
|
+
|
|
21
|
+
channel.onmessage = (e) => {
|
|
22
|
+
if (e.data.id === me) return // رسالتنا نحن
|
|
23
|
+
if (e.data.type === 'ping') {
|
|
24
|
+
s.peers = [...s.peers.filter((p) => p.id !== e.data.id), { id: e.data.id, at: e.data.at }]
|
|
25
|
+
} else if (e.data.type === 'bye') {
|
|
26
|
+
s.peers = s.peers.filter((p) => p.id !== e.data.id)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ping()
|
|
31
|
+
const timer = setInterval(() => {
|
|
32
|
+
ping()
|
|
33
|
+
s.peers = s.peers.filter((p) => Date.now() - p.at < 6000) // نسقط الصامت منذ 6 ثوانٍ
|
|
34
|
+
}, 3000)
|
|
35
|
+
|
|
36
|
+
window.addEventListener('beforeunload', () => channel.postMessage({ type: 'bye', id: me }))
|
|
37
|
+
Kreftjs.onDestroy(() => {
|
|
38
|
+
clearInterval(timer)
|
|
39
|
+
channel.postMessage({ type: 'bye', id: me })
|
|
40
|
+
channel.close()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
get peers() { return s.peers },
|
|
45
|
+
get count() { return s.peers.length },
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- كل تبويب يبثّ `ping` كل 3 ثوانٍ، والآخرون يجدّدون وجوده.
|
|
51
|
+
- التبويب الصامت (أُغلق أو جُمّد) يختفي بعد 6 ثوانٍ.
|
|
52
|
+
- `Kreftjs.onDestroy` ينظّف المؤقت والقناة حين يزول المكوّن — وكما في الفصل الثالث، المستمع المضاف في `on ready`/`on start` يُنظَّف وحده، أما هنا ننظّف يدويًا لأننا في ملف `.js`.
|
|
53
|
+
|
|
54
|
+
## 2) المنطق المشترك: طابور دون اتصال
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
// logic/offline.js — الأمر يُحفظ ويُرسل عند عودة الاتصال
|
|
58
|
+
export function useOfflineQueue() {
|
|
59
|
+
const s = Kreftjs.state({
|
|
60
|
+
online: navigator.onLine,
|
|
61
|
+
pending: Kreftjs.storage.get('offline-queue') || [],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
async function flush() {
|
|
65
|
+
const list = s.pending
|
|
66
|
+
if (!list.length) return
|
|
67
|
+
const done = []
|
|
68
|
+
for (const item of list) {
|
|
69
|
+
try {
|
|
70
|
+
await api.post(item.url, item.body)
|
|
71
|
+
done.push(item)
|
|
72
|
+
} catch (e) {
|
|
73
|
+
break // ما زلنا دون اتصال: توقف وأبقِ الباقي
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
s.pending = list.filter((x) => !done.includes(x))
|
|
77
|
+
Kreftjs.storage.set('offline-queue', s.pending)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function enqueue(url, body) {
|
|
81
|
+
s.pending = [...s.pending, { url, body }]
|
|
82
|
+
Kreftjs.storage.set('offline-queue', s.pending)
|
|
83
|
+
if (s.online) flush()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
window.addEventListener('online', () => { s.online = true; flush() })
|
|
87
|
+
window.addEventListener('offline', () => { s.online = false })
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
get online() { return s.online },
|
|
91
|
+
get pending() { return s.pending },
|
|
92
|
+
enqueue, flush,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- الطابور محفوظ في `Kreftjs.storage`، فينجو من إغلاق التبويب والجهاز.
|
|
98
|
+
- `flush` يرسل بالترتيب ويتوقف عند أول فشل (لا يرسل ما بعده خارج الترتيب).
|
|
99
|
+
- لا نرسل في كل مرة مباشرة: الأوامر تتكدس ثم تُغسل دفعة عند عودة الشبكة.
|
|
100
|
+
|
|
101
|
+
## 3) تطبيق ملاحظات يجمع الاثنين
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<!-- pages/Sync.kreft -->
|
|
105
|
+
import { usePresence } from '../logic/tabs.js'
|
|
106
|
+
import { useOfflineQueue } from '../logic/offline.js'
|
|
107
|
+
|
|
108
|
+
use presence = usePresence('notes')
|
|
109
|
+
use queue = useOfflineQueue()
|
|
110
|
+
|
|
111
|
+
// saved يتزامن بين التبويبات وحده (الفصل الثالث)
|
|
112
|
+
saved notes = []
|
|
113
|
+
saved draft = ''
|
|
114
|
+
|
|
115
|
+
action add() {
|
|
116
|
+
if (!draft.trim()) return
|
|
117
|
+
const text = draft
|
|
118
|
+
notes = [{ id: Date.now(), text }, ...notes]
|
|
119
|
+
draft = ''
|
|
120
|
+
queue.enqueue('/api/notes', { text })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
<view>
|
|
124
|
+
<h1>مزامنة حيّة</h1>
|
|
125
|
+
|
|
126
|
+
<p>
|
|
127
|
+
التبويبات المفتوحة الآن: <b>{presence.count}</b>
|
|
128
|
+
<span k-if="!queue.online" class="offline">· دون اتصال، ستُرسل لاحقًا</span>
|
|
129
|
+
</p>
|
|
130
|
+
|
|
131
|
+
<div class="row">
|
|
132
|
+
<input k-model="draft" @keydown.enter="add()" placeholder="اكتب ملاحظة…">
|
|
133
|
+
<button @click="add">أضف</button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{for n in notes key n.id}
|
|
137
|
+
<p class="note">{n.text}</p>
|
|
138
|
+
{empty}
|
|
139
|
+
<p>لا ملاحظات بعد.</p>
|
|
140
|
+
{/for}
|
|
141
|
+
|
|
142
|
+
<p k-if="queue.pending.length" class="pending">في الانتظار: {queue.pending.length} عنصر</p>
|
|
143
|
+
</view>
|
|
144
|
+
|
|
145
|
+
<style scoped>
|
|
146
|
+
.row { display: flex; gap: 8px; }
|
|
147
|
+
.offline { color: var(--danger, #b33); }
|
|
148
|
+
.pending { opacity: .7; }
|
|
149
|
+
</style>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## 4) ما لاحظناه أثناء الاختبار
|
|
153
|
+
|
|
154
|
+
- فتحنا التبويب نفسه مرتين: إضافة ملاحظة في الأول ظهرت في الثاني **فورًا** (بفضل `saved`).
|
|
155
|
+
- عدّاد "التبويبات المفتوحة" أصبح 1 ثم عاد 0 عند إغلاق التبويب الثاني (بعد 6 ثوانٍ كحد أقصى).
|
|
156
|
+
- بحجب الشبكة (DevTools › Network › Offline) ثم الإضافة: ظهرت الملاحظة محليًا وعداد "في الانتظار" = 1، وعند إعادة الاتصال أُرسل الطلب واختفى العداد.
|
|
157
|
+
|
|
158
|
+
## تنبيهات
|
|
159
|
+
|
|
160
|
+
- `BroadcastChannel` يعمل بين تبويبات **نفس الموقع** فقط، ولا يعمل بين متصفحين مختلفين.
|
|
161
|
+
- مزامنة `saved` بين التبويبات تقوم على حدث `storage` القياسي، فلا تغيّر القيمة المفتاحية (`as '…'`) إلا بقصد.
|
|
162
|
+
- الطابور هنا يحفظ الطلبات بلا مصادقة إضافية: في تطبيق حقيقي أضف رمز المستخدم ورتّب الأوامر، وفكّر في تعارض "آخر من يكتب يفوز".
|
|
163
|
+
|
|
164
|
+
## تمارين
|
|
165
|
+
|
|
166
|
+
1. أضف إلى `usePresence` اسم المستخدم ليعرض "سارة تعدّل الآن" بدل معرّف عشوائي.
|
|
167
|
+
2. اجعل الطابور يرسل أيضًا الأوامر المحذوفة (`DELETE`) بترتيبها مع الإنشاء.
|
|
168
|
+
3. أضف مؤشر "قيد المزامنة" دوّارًا يظهر أثناء `flush` وزر "أعد المحاولة الآن".
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# الفصل التاسع عشر (خبير): معالج إعداد متعدد الخطوات مع استئناف وحراسة المسارات
|
|
2
|
+
|
|
3
|
+
المعالج (Wizard) يجمع كل مهارات هذا الكتاب في مثال واحد: مخزن محفوظ للاستئناف، ومكوّنات متعددة تتبدل بـ `keep`، وتحقق لكل خطوة قبل الانتقال، وحراسة للمسارات تمنع القفز، وحركة خفيفة. سنبني "إعداد الحساب" من ثلاث خطوات.
|
|
4
|
+
|
|
5
|
+
**جُرِّبت الشيفرة** خطوة خطوة في المتصفح.
|
|
6
|
+
|
|
7
|
+
## 1) المخزن المحفوظ (القلب النابض)
|
|
8
|
+
|
|
9
|
+
حالة المعالج تعيش في مخزن واحد محفوظ، فتنجو من إعادة التحميل وتُشارك بين الصفحات:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
// logic/wizard.js — أنشئه مرة قبل الموجِّه
|
|
13
|
+
Kreftjs.store('wizard', {
|
|
14
|
+
step: 1,
|
|
15
|
+
data: { name: '', email: '', plan: 'basic' },
|
|
16
|
+
completed: false,
|
|
17
|
+
next() { if (this.step < 3) this.step++ },
|
|
18
|
+
prev() { if (this.step > 1) this.step-- },
|
|
19
|
+
finish() { this.completed = true },
|
|
20
|
+
}, { saved: true })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `saved: true` يحفظ `step` و`data` و`completed` في `localStorage` (الدوال لا تُحفظ).
|
|
24
|
+
- كل مكوّن يكتب `use wizard` فيرى الحالة نفسها ويغيّرها، وكل تغيير يحدّث كل من يقرؤها.
|
|
25
|
+
|
|
26
|
+
## 2) الخطوات الثلاث
|
|
27
|
+
|
|
28
|
+
كل خطوة مكوّن مستقل يربط حقوله مباشرة بالمخزن:
|
|
29
|
+
|
|
30
|
+
```html
|
|
31
|
+
<!-- pages/wizard/Step1.kreft -->
|
|
32
|
+
use wizard
|
|
33
|
+
|
|
34
|
+
<view>
|
|
35
|
+
<h2>١) معلوماتك</h2>
|
|
36
|
+
<label>الاسم
|
|
37
|
+
<input k-model="wizard.data.name" placeholder="اسمك الكامل">
|
|
38
|
+
</label>
|
|
39
|
+
<label>البريد
|
|
40
|
+
<input type="email" k-model="wizard.data.email" placeholder="you@example.com">
|
|
41
|
+
</label>
|
|
42
|
+
</view>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```html
|
|
46
|
+
<!-- pages/wizard/Step2.kreft -->
|
|
47
|
+
use wizard
|
|
48
|
+
|
|
49
|
+
<view>
|
|
50
|
+
<h2>٢) خطتك</h2>
|
|
51
|
+
<label><input type="radio" value="basic" k-model="wizard.data.plan"> أساسية</label>
|
|
52
|
+
<label><input type="radio" value="pro" k-model="wizard.data.plan"> احترافية</label>
|
|
53
|
+
</view>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```html
|
|
57
|
+
<!-- pages/wizard/Step3.kreft -->
|
|
58
|
+
use wizard
|
|
59
|
+
|
|
60
|
+
<view>
|
|
61
|
+
<h2>٣) راجع بياناتك</h2>
|
|
62
|
+
<dl>
|
|
63
|
+
<dt>الاسم</dt><dd>{wizard.data.name}</dd>
|
|
64
|
+
<dt>البريد</dt><dd>{wizard.data.email}</dd>
|
|
65
|
+
<dt>الخطة</dt><dd>{wizard.data.plan}</dd>
|
|
66
|
+
</dl>
|
|
67
|
+
</view>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 3) صفحة المعالج
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<!-- pages/wizard/Wizard.kreft -->
|
|
74
|
+
use wizard
|
|
75
|
+
|
|
76
|
+
state errors = {}
|
|
77
|
+
|
|
78
|
+
const stepRules = {
|
|
79
|
+
1: { name: 'required|min:2', email: 'required|email' },
|
|
80
|
+
2: { plan: 'required' },
|
|
81
|
+
3: {},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
action next() {
|
|
85
|
+
errors = validate(wizard.data, stepRules[wizard.step] || {})
|
|
86
|
+
if (Object.keys(errors).length) return // أبقِ المستخدم في خطوته
|
|
87
|
+
wizard.next()
|
|
88
|
+
}
|
|
89
|
+
action prev() { wizard.prev() }
|
|
90
|
+
action finish() { wizard.finish(); $router.go('/wizard/done') }
|
|
91
|
+
|
|
92
|
+
derive pct = Math.round(((wizard.step - 1) / 2) * 100)
|
|
93
|
+
|
|
94
|
+
<view>
|
|
95
|
+
<h1>إعداد الحساب</h1>
|
|
96
|
+
|
|
97
|
+
<div class="progress" aria-hidden="true">
|
|
98
|
+
<div class="bar" style="width: {pct}%"></div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<!-- keep يبقي حالة كل خطوة حين نعود إليها -->
|
|
102
|
+
<kreft-component :is="'Step' + wizard.step" keep></kreft-component>
|
|
103
|
+
|
|
104
|
+
<p k-if="Object.keys(errors).length" class="errors">
|
|
105
|
+
{Object.values(errors).flat().join(' · ')}
|
|
106
|
+
</p>
|
|
107
|
+
|
|
108
|
+
<div class="actions">
|
|
109
|
+
<button k-if="wizard.step > 1" @click="prev">السابق</button>
|
|
110
|
+
<button k-if="wizard.step < 3" class="primary" @click="next">التالي</button>
|
|
111
|
+
<button k-if="wizard.step === 3" class="primary" @click="finish">إنهاء</button>
|
|
112
|
+
</div>
|
|
113
|
+
</view>
|
|
114
|
+
|
|
115
|
+
<style scoped>
|
|
116
|
+
.progress { height: 8px; background: var(--border, #ddd); border-radius: 4px; overflow: hidden; }
|
|
117
|
+
.bar { height: 100%; background: var(--accent, #4a7); transition: width .25s ease; }
|
|
118
|
+
.actions { display: flex; gap: 8px; margin-block-start: 16px; }
|
|
119
|
+
.errors { color: var(--danger, #b33); }
|
|
120
|
+
</style>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **`validate`** قبل `wizard.next()`: لا ينتقل المستخدم إلا بعد أن تسلم حقول خطوته.
|
|
124
|
+
- **`keep`** على `<kreft-component :is>` يبقي ما كتبه المستخدم في الخطوة 1 حين يعود إليها من الخطوة 2.
|
|
125
|
+
- **`derive pct`** يحرّك شريط التقدم وحده.
|
|
126
|
+
|
|
127
|
+
## 4) التسجيل والحراسة
|
|
128
|
+
|
|
129
|
+
سجّل مكوّنات الخطوات وأنشئ الموجّه (أنشئ المخزن أولًا):
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
Kreftjs.store('wizard', { /* كما أعلاه */ }, { saved: true })
|
|
133
|
+
|
|
134
|
+
const wizard = Kreftjs.store('wizard') // نقرؤه في الحراسة
|
|
135
|
+
|
|
136
|
+
const router = Kreftjs.router({
|
|
137
|
+
routes: [
|
|
138
|
+
{ path: '/wizard', component: 'pages/wizard/Wizard.kreft', guard: () => auth.user ? true : '/login' },
|
|
139
|
+
{ path: '/wizard/done', component: 'pages/wizard/Done.kreft', guard: () => wizard.completed ? true : '/wizard' },
|
|
140
|
+
],
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
Kreftjs.app('App.kreft')
|
|
144
|
+
.component('Step1', 'pages/wizard/Step1.kreft')
|
|
145
|
+
.component('Step2', 'pages/wizard/Step2.kreft')
|
|
146
|
+
.component('Step3', 'pages/wizard/Step3.kreft')
|
|
147
|
+
.use(router)
|
|
148
|
+
.mount('#app')
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- حراسة `/wizard` تتطلب الدخول، وحراسة `/wizard/done` تمنع فتح صفحة الإنجاز قبل `finish()`: من يكتب العنوان مباشرة يُعاد إلى `/wizard`.
|
|
152
|
+
- `guard` يعيد `true` للمرور أو مسارًا للذهاب إليه (راجع الفصل الرابع).
|
|
153
|
+
|
|
154
|
+
## 5) صفحة الإنجاز
|
|
155
|
+
|
|
156
|
+
```html
|
|
157
|
+
<!-- pages/wizard/Done.kreft -->
|
|
158
|
+
use wizard
|
|
159
|
+
|
|
160
|
+
action startOver() { wizard.$reset(); $router.go('/wizard') }
|
|
161
|
+
|
|
162
|
+
<view>
|
|
163
|
+
<div k-animate="pop">
|
|
164
|
+
<h2>اكتمل الإعداد 🎉</h2>
|
|
165
|
+
<p>مرحبًا {wizard.data.name} — خطتك {wizard.data.plan}.</p>
|
|
166
|
+
<button @click="startOver">ابدأ من جديد</button>
|
|
167
|
+
</div>
|
|
168
|
+
</view>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`wizard.$reset()` يعيد المخزن إلى قيمه الأولى (الخطوة 1 وبيانات فارغة)، فتُحذف كل الآثار المحفوظة.
|
|
172
|
+
|
|
173
|
+
## 6) لمسة حركة: انتقال بين الخطوات
|
|
174
|
+
|
|
175
|
+
`keep` يُبقي الخطوة ولا يزيلها، فلا تصلح له حركة الدخول/الخروج مباشرة. إن أردت انتقالًا متحركًا استبدل `keep` بـ `k-if` مع `k-animate.after` (ينتظر خروج القديم قبل دخول الجديد):
|
|
176
|
+
|
|
177
|
+
```html
|
|
178
|
+
<div k-if="wizard.step === 1" k-animate.after="slide"><step1></step1></div>
|
|
179
|
+
<div k-if="wizard.step === 2" k-animate.after="slide"><step2></step2></div>
|
|
180
|
+
<div k-if="wizard.step === 3" k-animate.after="slide"><step3></step3></div>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
التضحية أنك تفقد `keep` (لا يُحفظ ما كتبته في الخطوة 1 حين تنتقل). اختر حسب حاجتك: **الاحتفاظ** (keep) أو **الحركة** (k-animate.after).
|
|
184
|
+
|
|
185
|
+
## 7) جرّب الاستئناف بنفسك
|
|
186
|
+
|
|
187
|
+
1. افتح `/wizard` واملأ الخطوة 1 وانتقل للثانية.
|
|
188
|
+
2. حدّث الصفحة (F5) — ستجد نفسك في الخطوة 2 وبياناتك محفوظة (بفضل `saved`).
|
|
189
|
+
3. اكتب `/wizard/done` مباشرة — يعيدك الحارس إلى `/wizard` لأنك لم تُنهِ بعد.
|
|
190
|
+
4. أنهِ الخطوات ثم افتح `/wizard/done` — تمر هذه المرة.
|
|
191
|
+
|
|
192
|
+
## تمارين
|
|
193
|
+
|
|
194
|
+
1. أضف خطوة رابعة "صورة" ترفع ملفًا وتخزّن مساره في `wizard.data`.
|
|
195
|
+
2. اجعل الحارس يمنع أيضًا العودة إلى خطوة سابقة بعد الإنهاء (أعد التوجيه إلى `/wizard/done`).
|
|
196
|
+
3. استبدل `keep` بنسخة `k-animate.after` واحفظ البيانات المؤقتة في `saved` كي لا تضيع عند الانتقال.
|